├── LICENSE.md ├── README.md ├── kamereon ├── __init__.py ├── binary_sensor.py ├── climate.py ├── device_tracker.py ├── kamereon.py ├── lock.py ├── manifest.json ├── sensor.py └── switch.py └── requirements.txt /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kamereon-python 2 | 3 | Kamereon is platform used for connected cars from the Renault-Nissan-Mitsubishi Alliance from 2019 onwards. 4 | 5 | ## Compatible models 6 | 7 | Theoretically... 8 | 9 | * Nissan Navara (from July 2019) 10 | * Nissan Leaf (from May 2019) 11 | * Nissan Juke (from November 2019) 12 | * Renault Zoe? 13 | 14 | ## Example usage 15 | 16 | pip install -r requirements.txt 17 | python kamereon/__init__.py EU my-email-login@foo.bar my-password 18 | 19 | ## This project 20 | 21 | Is a proof-of-concept, and my goal is to build this into a general library for this platform, and use that to build a [Home Assistant](https://www.home-assistant.io/) component. 22 | 23 | ### License 24 | 25 | Apache 2 26 | 27 | ## References 28 | 29 | This wouldn't have been half as easy to put together if it wasn't for the work by [Tobias Westergaard Kjeldsen](https://gitlab.com/tobiaswkjeldsen) on reverse engineering Nissan Connect Services apps for his [dartnissanconnect](https://gitlab.com/tobiaswkjeldsen/dartnissanconnect) library and [My Leaf app](https://gitlab.com/tobiaswkjeldsen/carwingsflutter) 30 | 31 | ### Acronyms and initialisms 32 | 33 | * SVT: Stolen Vehicle Tracking 34 | * SRP: ?? PIN (PIN used for remote control actions) 35 | * FOTA: ??? 36 | * EPS: ??? 37 | * RGDC: ??? 38 | * NCI: Nissan Connect ??? 39 | * NCB: Nissan Connect ??? 40 | * ACMS: ??? the server-side platform used to handle requests 41 | * ICE: Internal Combustion Engine 42 | * RHL: Remote Horn / Lights 43 | * RC: Remove Charge 44 | * RLU: Remote Lock / Unlock 45 | * BCI: ? 46 | * RES: Remote Electric Start / Stop? 47 | * CCS: Climate Control System 48 | * RPC/RPU: ? 49 | * SVTB: Stolen Vehicle Tracking Block / Unblock -------------------------------------------------------------------------------- /kamereon/__init__.py: -------------------------------------------------------------------------------- 1 | """Support for Kamereon-supporting cars.""" 2 | import asyncio 3 | from datetime import timedelta 4 | import logging 5 | 6 | import voluptuous as vol 7 | 8 | from homeassistant.const import ( 9 | CONF_PASSWORD, 10 | CONF_SCAN_INTERVAL, 11 | CONF_USERNAME, 12 | ) 13 | from homeassistant.helpers import discovery 14 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 15 | import homeassistant.helpers.config_validation as cv 16 | from homeassistant.helpers.dispatcher import ( 17 | async_dispatcher_connect, 18 | async_dispatcher_send, 19 | ) 20 | from homeassistant.helpers.entity import Entity 21 | from homeassistant.helpers.event import async_track_point_in_utc_time 22 | from homeassistant.util.dt import utcnow 23 | 24 | from .kamereon import NCISession 25 | 26 | DOMAIN = "kamereon" 27 | 28 | DATA_KEY = DOMAIN 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | MIN_UPDATE_INTERVAL = timedelta(minutes=1) 33 | DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5) 34 | 35 | CONF_MANUFACTURER = 'manufacturer' 36 | CONF_REGION = "region" 37 | 38 | SIGNAL_STATE_UPDATED = f"{DOMAIN}.updated" 39 | 40 | MANUFACTURERS = { 41 | 'nissan': NCISession, 42 | } 43 | 44 | SUB_SCHEMA = vol.Schema({ 45 | vol.Required(CONF_MANUFACTURER): vol.All(cv.string, vol.In(MANUFACTURERS)), 46 | vol.Required(CONF_USERNAME): cv.string, 47 | vol.Required(CONF_PASSWORD): cv.string, 48 | vol.Optional( 49 | CONF_SCAN_INTERVAL, default=DEFAULT_UPDATE_INTERVAL 50 | ): vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL)), 51 | vol.Optional(CONF_REGION): cv.string, 52 | }) 53 | 54 | CONFIG_SCHEMA = vol.Schema( 55 | { 56 | vol.Optional(DOMAIN): vol.All(cv.ensure_list, [SUB_SCHEMA]) 57 | }, 58 | extra=vol.ALLOW_EXTRA, 59 | ) 60 | 61 | 62 | async def async_setup(hass, config): 63 | """Set up the Kamereon component.""" 64 | session = async_get_clientsession(hass) 65 | entry_setup = [] 66 | for config_entry in config[DOMAIN]: 67 | entry_setup.append(_async_setup_entry(hass, config_entry, session)) 68 | 69 | return all(await asyncio.gather(*entry_setup)) 70 | 71 | async def _async_setup_entry(hass, config, session): 72 | 73 | mfr_session_class = MANUFACTURERS[config.get(CONF_MANUFACTURER)] 74 | kamereon_session = mfr_session_class( 75 | region=config.get(CONF_REGION) 76 | #session=session, 77 | ) 78 | 79 | interval = config[CONF_SCAN_INTERVAL] 80 | 81 | data = hass.data[DATA_KEY] = {} 82 | 83 | def discover_vehicle(vehicle): 84 | """Load relevant platforms.""" 85 | 86 | for component in ('binary_sensor', 'climate', 'device_tracker', 'lock', 'sensor', 'switch'): 87 | hass.async_create_task( 88 | discovery.async_load_platform( 89 | hass, 90 | component, 91 | DOMAIN, 92 | vehicle, 93 | config, 94 | ) 95 | ) 96 | 97 | data[vehicle.vin] = vehicle 98 | 99 | async def update(now): 100 | """Update status from the online service.""" 101 | try: 102 | 103 | for vehicle in kamereon_session.fetch_vehicles(): 104 | vehicle.refresh() 105 | if vehicle.vin not in data: 106 | discover_vehicle(vehicle) 107 | 108 | async_dispatcher_send(hass, SIGNAL_STATE_UPDATED) 109 | 110 | return True 111 | finally: 112 | async_track_point_in_utc_time(hass, update, utcnow() + interval) 113 | 114 | _LOGGER.info("Logging in to service") 115 | kamereon_session.login( 116 | username=config.get(CONF_USERNAME), 117 | password=config.get(CONF_PASSWORD) 118 | ) 119 | return await update(utcnow()) 120 | 121 | 122 | class KamereonEntity(Entity): 123 | """Base class for all Kamereon car entities.""" 124 | 125 | def __init__(self, vehicle): 126 | """Initialize the entity.""" 127 | self.vehicle = vehicle 128 | 129 | async def async_added_to_hass(self): 130 | """Register update dispatcher.""" 131 | async_dispatcher_connect( 132 | self.hass, SIGNAL_STATE_UPDATED, self.async_schedule_update_ha_state 133 | ) 134 | 135 | @property 136 | def icon(self): 137 | """Return the icon.""" 138 | return 'mdi:car' 139 | 140 | @property 141 | def _entity_name(self): 142 | return None 143 | 144 | @property 145 | def _vehicle_name(self): 146 | return self.vehicle.nickname or self.vehicle.model_name 147 | 148 | @property 149 | def name(self): 150 | """Return full name of the entity.""" 151 | if not self._entity_name: 152 | return self._vehicle_name 153 | return f"{self._vehicle_name} {self._entity_name}" 154 | 155 | @property 156 | def should_poll(self): 157 | """Return the polling state.""" 158 | return False 159 | 160 | @property 161 | def assumed_state(self): 162 | """Return true if unable to access real state of entity.""" 163 | return True 164 | 165 | @property 166 | def device_state_attributes(self): 167 | """Return device specific state attributes.""" 168 | return { 169 | 'manufacturer': self.vehicle.session.tenant, 170 | 'vin': self.vehicle.vin, 171 | 'name': self.vehicle.nickname or self.vehicle.model_name, 172 | 'model': self.vehicle.model_name, 173 | 'color': self.vehicle.color, 174 | 'registration_number': self.vehicle.registration_number, 175 | 'device_picture': self.vehicle.picture_url, 176 | 'first_registration_date': self.vehicle.first_registration_date, 177 | } 178 | 179 | @property 180 | def device_info(self): 181 | return { 182 | 'identifiers': (DOMAIN, self.vehicle.session.tenant, self.vehicle.vin), 183 | 'manufacturer': self.vehicle.session.tenant, 184 | 'vin': self.vehicle.vin, 185 | } -------------------------------------------------------------------------------- /kamereon/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Kamereon cars.""" 2 | import logging 3 | 4 | from homeassistant.components.binary_sensor import DEVICE_CLASSES, BinarySensorDevice 5 | from homeassistant.const import STATE_UNKNOWN 6 | 7 | from . import KamereonEntity 8 | from .kamereon import ChargingStatus, Door, LockStatus, PluggedStatus 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | async def async_setup_platform(hass, config, async_add_entities, vehicle=None): 14 | """Set up the Kamereon sensors.""" 15 | if vehicle is None: 16 | return 17 | async_add_entities([ 18 | ChargingStatusEntity(vehicle), 19 | PluggedStatusEntity(vehicle), 20 | FuelLowWarningEntity(vehicle), 21 | DoorEntity(vehicle, Door.FRONT_LEFT), 22 | DoorEntity(vehicle, Door.FRONT_RIGHT), 23 | DoorEntity(vehicle, Door.REAR_LEFT), 24 | DoorEntity(vehicle, Door.REAR_RIGHT), 25 | DoorEntity(vehicle, Door.HATCH), 26 | ]) 27 | 28 | 29 | class ChargingStatusEntity(KamereonEntity, BinarySensorDevice): 30 | """Representation of charging status.""" 31 | 32 | @property 33 | def _entity_name(self): 34 | return 'charging' 35 | 36 | @property 37 | def icon(self): 38 | """Return the icon.""" 39 | return 'mdi:{}'.format('battery-charging' if self.is_on else 'battery-off') 40 | 41 | @property 42 | def is_on(self): 43 | """Return True if the binary sensor is on.""" 44 | if self.vehicle.charging is None: 45 | return STATE_UNKNOWN 46 | return self.vehicle.charging is ChargingStatus.CHARGING 47 | 48 | @property 49 | def device_class(self): 50 | """Return the class of this sensor.""" 51 | return 'power' 52 | 53 | @property 54 | def device_state_attributes(self): 55 | a = KamereonEntity.device_state_attributes.fget(self) 56 | a.update({ 57 | 'charging_speed': self.vehicle.charging_speed.value, 58 | 'last_updated': self.vehicle.battery_status_last_updated, 59 | }) 60 | return a 61 | 62 | 63 | class PluggedStatusEntity(KamereonEntity, BinarySensorDevice): 64 | """Representation of plugged status.""" 65 | 66 | @property 67 | def _entity_name(self): 68 | return 'plugged_in' 69 | 70 | @property 71 | def icon(self): 72 | """Return the icon.""" 73 | return 'mdi:{}'.format('power-plug' if self.is_on else 'power-plug-off') 74 | 75 | @property 76 | def is_on(self): 77 | """Return True if the binary sensor is on.""" 78 | if self.vehicle.plugged_in is None: 79 | return STATE_UNKNOWN 80 | return self.vehicle.plugged_in is PluggedStatus.PLUGGED 81 | 82 | @property 83 | def device_class(self): 84 | """Return the class of this sensor.""" 85 | return 'plug' 86 | 87 | @property 88 | def device_state_attributes(self): 89 | a = KamereonEntity.device_state_attributes.fget(self) 90 | a.update({ 91 | 'plugged_in_time': self.vehicle.plugged_in_time, 92 | 'unplugged_time': self.vehicle.unplugged_time, 93 | 'last_updated': self.vehicle.battery_status_last_updated, 94 | }) 95 | return a 96 | 97 | 98 | class FuelLowWarningEntity(KamereonEntity, BinarySensorDevice): 99 | """Representation of fuel low warning status.""" 100 | 101 | @property 102 | def _entity_name(self): 103 | return 'fuel_low' 104 | 105 | @property 106 | def icon(self): 107 | """Return the icon.""" 108 | return 'mdi:fuel' 109 | 110 | @property 111 | def is_on(self): 112 | """Return True if the binary sensor is on.""" 113 | if self.vehicle.fuel_low_warning is None: 114 | return STATE_UNKNOWN 115 | return self.vehicle.fuel_low_warning 116 | 117 | @property 118 | def device_class(self): 119 | """Return the class of this sensor.""" 120 | return 'safety' 121 | 122 | 123 | class DoorEntity(KamereonEntity, BinarySensorDevice): 124 | """Representation of a door (or hatch).""" 125 | 126 | def __init__(self, vehicle, door): 127 | KamereonEntity.__init__(self, vehicle) 128 | self.door = door 129 | 130 | @property 131 | def icon(self): 132 | """Return the icon.""" 133 | return 'mdi:car-door' 134 | 135 | @property 136 | def _entity_name(self): 137 | return '{}_door'.format(self.door.value) 138 | 139 | @property 140 | def is_on(self): 141 | """Return True if the binary sensor is open.""" 142 | if self.door not in self.vehicle.door_status or self.vehicle.door_status[self.door] is None: 143 | return STATE_UNKNOWN 144 | return self.vehicle.door_status[self.door] == LockStatus.OPEN 145 | 146 | @property 147 | def device_class(self): 148 | """Return the class of this sensor.""" 149 | return 'door' -------------------------------------------------------------------------------- /kamereon/climate.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for Kamereon Platform 3 | """ 4 | import logging 5 | 6 | from homeassistant.components.climate import ClimateDevice 7 | from homeassistant.components.climate.const import (HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, SUPPORT_TARGET_TEMPERATURE) 8 | from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN, TEMP_CELSIUS 9 | 10 | SUPPORT_HVAC = [HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF] 11 | 12 | from . import KamereonEntity 13 | from .kamereon import Feature, HVACAction, HVACStatus 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | def setup_platform(hass, config, add_devices, vehicle=None): 19 | """ Setup the volkswagen climate.""" 20 | if vehicle is None: 21 | return 22 | if Feature.TEMPERATURE in vehicle.features or Feature.INTERIOR_TEMP_SETTINGS in vehicle.features: 23 | add_devices([KamereonClimate(vehicle)]) 24 | 25 | 26 | class KamereonClimate(KamereonEntity, ClimateDevice): 27 | """Representation of a Kamereon Climate.""" 28 | 29 | @property 30 | def supported_features(self): 31 | """Return the list of supported features.""" 32 | return SUPPORT_TARGET_TEMPERATURE 33 | 34 | @property 35 | def hvac_mode(self): 36 | """Return hvac operation ie. heat, cool mode. 37 | Need to be one of HVAC_MODE_*. 38 | """ 39 | if self.vehicle.hvac_status is None: 40 | return STATE_UNKNOWN 41 | elif self.vehicle.hvac_status is HVACStatus.ON: 42 | return HVAC_MODE_HEAT_COOL 43 | return HVAC_MODE_OFF 44 | 45 | 46 | @property 47 | def hvac_modes(self): 48 | """Return the list of available hvac operation modes. 49 | Need to be a subset of HVAC_MODES. 50 | """ 51 | return SUPPORT_HVAC 52 | 53 | @property 54 | def temperature_unit(self): 55 | """Return the unit of measurement.""" 56 | return TEMP_CELSIUS 57 | 58 | @property 59 | def current_temperature(self): 60 | """Return the current temperature.""" 61 | if self.vehicle.internal_temperature: 62 | return float(self.vehicle.internal_temperature) 63 | return None 64 | 65 | @property 66 | def target_temperature(self): 67 | """Return the temperature we try to reach.""" 68 | if self.vehicle.next_target_temperature is not None: 69 | return float(self.vehicle.next_target_temperature) 70 | return None 71 | 72 | def set_temperature(self, **kwargs): 73 | """Set new target temperatures.""" 74 | if Feature.TEMPERATURE not in self.vehicle.features: 75 | raise NotImplementedError() 76 | 77 | _LOGGER.debug("Setting temperature for: %s", self.instrument.attr) 78 | temperature = kwargs.get(ATTR_TEMPERATURE) 79 | if temperature: 80 | self.vehicle.set_hvac_status(HVACAction.START, temperature) 81 | 82 | def set_hvac_mode(self, hvac_mode): 83 | """Set new target hvac mode.""" 84 | if Feature.CLIMATE_ON_OFF not in self.vehicle.features: 85 | raise NotImplementedError() 86 | 87 | _LOGGER.debug("Setting mode for: %s", self.instrument.attr) 88 | if hvac_mode == HVAC_MODE_OFF: 89 | self.vehicle.set_hvac_status(HVACAction.STOP) 90 | elif hvac_mode == HVAC_MODE_HEAT_COOL: 91 | self.vehicle.set_hvac_status(HVACAction.START) -------------------------------------------------------------------------------- /kamereon/device_tracker.py: -------------------------------------------------------------------------------- 1 | """Support for tracking a Kamereon car.""" 2 | import logging 3 | 4 | from homeassistant.components.device_tracker import SOURCE_TYPE_GPS 5 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 6 | from homeassistant.util import slugify 7 | 8 | from . import SIGNAL_STATE_UPDATED 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | async def async_setup_scanner(hass, config, async_see, vehicle=None): 14 | """Set up the Kamereon tracker.""" 15 | if vehicle is None: 16 | return 17 | 18 | async def see_vehicle(): 19 | """Handle the reporting of the vehicle position.""" 20 | host_name = slugify(vehicle.nickname or vehicle.model_name) 21 | await async_see( 22 | dev_id=host_name, 23 | host_name=host_name, 24 | source_type=SOURCE_TYPE_GPS, 25 | gps=vehicle.location, 26 | attributes={ 27 | 'last_updated': vehicle.location_last_updated.isoformat(), 28 | 'manufacturer': vehicle.session.tenant, 29 | 'vin': vehicle.vin, 30 | 'name': vehicle.nickname or vehicle.model_name, 31 | 'model': vehicle.model_name, 32 | 'registration_number': vehicle.registration_number, 33 | }, 34 | icon="mdi:car", 35 | ) 36 | 37 | async_dispatcher_connect(hass, SIGNAL_STATE_UPDATED, see_vehicle) 38 | 39 | return True -------------------------------------------------------------------------------- /kamereon/kamereon.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Richard Mitchell 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import collections 16 | import datetime 17 | import enum 18 | import json 19 | import os 20 | from typing import List 21 | from urllib.parse import urljoin, urlparse, parse_qs 22 | 23 | from oauthlib.common import generate_nonce 24 | import pytz 25 | import requests 26 | from requests_oauthlib import OAuth2Session 27 | 28 | 29 | API_VERSION = 'protocol=1.0,resource=2.1' 30 | SRP_KEY = 'D5AF0E14718E662D12DBB4FE42304DF5A8E48359E22261138B40AA16CC85C76A11B43200A1EECB3C9546A262D1FBD51ACE6FCDE558C00665BBF93FF86B9F8F76AA7A53CA74F5B4DFF9A4B847295E7D82450A2078B5A28814A7A07F8BBDD34F8EEB42B0E70499087A242AA2C5BA9513C8F9D35A81B33A121EEF0A71F3F9071CCD' 31 | 32 | 33 | settings_map = { 34 | 'nissan': { 35 | 'EU': { 36 | 'client_id': 'a-ncb-prod-android', 37 | 'client_secret': '3LBs0yOx2XO-3m4mMRW27rKeJzskhfWF0A8KUtnim8i/qYQPl8ZItp3IaqJXaYj_', 38 | 'scope': 'openid profile vehicles', 39 | 'auth_base_url': 'https://prod.eu.auth.kamereon.org/kauth/', 40 | 'realm': 'a-ncb-prod', 41 | 'redirect_uri': 'org.kamereon.service.nci:/oauth2redirect', 42 | 'car_adapter_base_url': 'https://alliance-platform-caradapter-prod.apps.eu.kamereon.io/car-adapter/', 43 | 'notifications_base_url': 'https://alliance-platform-notifications-prod.apps.eu.kamereon.io/notifications/', 44 | 'user_adapter_base_url': 'https://alliance-platform-usersadapter-prod.apps.eu.kamereon.io/user-adapter/', 45 | 'user_base_url': 'https://nci-bff-web-prod.apps.eu.kamereon.io/bff-web/', 46 | }, 47 | 'JP': {}, 48 | 'RU': {}, 49 | }, 50 | 'mitsubishi': {}, 51 | 'renault': {}, 52 | } 53 | 54 | 55 | USERS = 'users' 56 | VEHICLES = 'vehicles' 57 | CATEGORIES = 'categories' 58 | NOTIFICATION_RULES = 'notification_rules' 59 | NOTIFICATION_TYPES = 'notification_types' 60 | NOTIFICATION_CATEGORIES = 'notification_categories' 61 | _registry = { 62 | USERS: {}, 63 | VEHICLES: {}, 64 | CATEGORIES: {}, 65 | NOTIFICATION_RULES: {}, 66 | NOTIFICATION_TYPES: {}, 67 | NOTIFICATION_CATEGORIES: {}, 68 | } 69 | 70 | 71 | class HVACAction(enum.Enum): 72 | # Start or schedule start 73 | START = 'start' 74 | # Stop active HVAC 75 | STOP = 'stop' 76 | # Cancel scheduled HVAC 77 | CANCEL = 'cancel' 78 | 79 | 80 | class HVACStatus(enum.Enum): 81 | OFF = 'off' 82 | ON = 'on' 83 | 84 | 85 | class LockStatus(enum.Enum): 86 | CLOSED = 'closed' 87 | LOCKED = 'locked' 88 | OPEN = 'open' 89 | UNLOCKED = 'unlocked' 90 | 91 | 92 | class Door(enum.Enum): 93 | HATCH = 'hatch' 94 | FRONT_LEFT = 'front_left' 95 | FRONT_RIGHT = 'front_right' 96 | REAR_LEFT = 'rear_left' 97 | REAR_RIGHT = 'rear_right' 98 | 99 | 100 | class LockableDoorGroup(enum.Enum): 101 | DOORS_AND_HATCH = 'doors_hatch' 102 | DRIVERS_DOOR = 'driver_s_door' 103 | HATCH = 'hatch' 104 | 105 | 106 | class ChargingSpeed(enum.Enum): 107 | NONE = None 108 | SLOW = 1 109 | NORMAL = 2 110 | FAST = 3 111 | 112 | 113 | class ChargingStatus(enum.Enum): 114 | ERROR = -1 115 | NOT_CHARGING = 0 116 | CHARGING = 1 117 | 118 | 119 | class PluggedStatus(enum.Enum): 120 | ERROR = -1 121 | NOT_PLUGGED = 0 122 | PLUGGED = 1 123 | 124 | 125 | class Period(enum.Enum): 126 | DAILY = 0 127 | MONTHLY = 1 128 | YEARLY = 2 129 | 130 | 131 | class Feature(enum.Enum): 132 | BREAKDOWN_ASSISTANCE_CALL = '1' 133 | SVT_WITH_VEHICLE_BLOCKAGE = '10' 134 | MAINTENANCE_ALERT = '101' 135 | VEHICLE_SOFTWARE_UPDATES = '107' 136 | MY_CAR_FINDER = '12' 137 | MIL_ON_NOTIFICATION = '15' 138 | VEHICLE_HEALTH_REPORT = '18' 139 | ADVANCED_CAN = '201' 140 | VEHICLE_STATUS_CHECK = '202' 141 | LOCK_STATUS_CHECK = '2021' 142 | NAVIGATION_FACTORY_RESET = '208' 143 | MESSAGES_TO_THE_VEHICLE = '21' 144 | VEHICLE_DATA = '2121' 145 | VEHICLE_DATA_2 = '2122' 146 | VEHICLE_WIFI = '213' 147 | ADVANCED_VEHICLE_DIAGNOSTICS = '215' 148 | NAVIGATION_MAP_UPDATES = '217' 149 | VEHICLE_SETTINGS_TRANSFER = '221' 150 | LAST_MILE_NAVIGATION = '227' 151 | GOOGLE_STREET_VIEW = '229' 152 | GOOGLE_SATELITE_VIEW = '230' 153 | DYNAMIC_EV_ICE_RANGE = '232' 154 | ECO_ROUTE_CALCULATION = '233' 155 | CO_PILOT = '234' 156 | DRIVING_JOURNEY_HISTORY = '235' 157 | NISSAN_RENAULT_BROADCASTS = '241' 158 | ONLINE_PARKING_INFO = '243' 159 | ONLINE_RESTAURANT_INFO = '244' 160 | ONLINE_SPEED_RESTRICTION_INFO = '245' 161 | WEATHER_INFO = '246' 162 | VEHICLE_ACCESS_TO_EMAIL = '248' 163 | VEHICLE_ACCESS_TO_MUSIC = '249' 164 | VEHICLE_ACCESS_TO_CONTACTS = '262' 165 | APP_DOOR_LOCKING = '27' 166 | GLONASS = '276' 167 | ZONE_ALERT = '281' 168 | SPEEDING_ALERT = '282' 169 | SERVICE_SUBSCRIPTION = '284' 170 | PAY_HOW_YOU_DRIVE = '286' 171 | CHARGING_SPOT_INFO = '288' 172 | FLEET_ASSET_INFORMATION = '29' 173 | CHARGING_SPOT_INFO_COLLECTION = '292' 174 | CHARGING_START = '299' 175 | CHARGING_STOP = '303' 176 | INTERIOR_TEMP_SETTINGS = '307' 177 | CLIMATE_ON_OFF_NOTIFICATION = '311' 178 | CHARGING_SPOT_SEARCH = '312' 179 | PLUG_IN_REMINDER = '314' 180 | CHARGING_STOP_NOTIFICATION = '317' 181 | BATTERY_STATUS = '319' 182 | BATTERY_HEATING_NOTIFICATION = '320' 183 | VEHICLE_STATE_OF_CHARGE_PERCENT = '322' 184 | BATTERY_STATE_OF_HEALTH_PERCENT = '323' 185 | PAY_AS_YOU_DRIVE = '34' 186 | DRIVING_ANALYSIS = '340' 187 | CO2_GAS_SAVINGS = '341' 188 | ELECTRICITY_FEE_CALCULATION = '342' 189 | CHARGING_CONSUMPTION_HISTORY = '344' 190 | BATTERY_MONITORING = '345' 191 | BATTERY_DATA = '347' 192 | APP_BASED_NAVIGATION = '35' 193 | CHARGING_SPOT_UPDATES = '354' 194 | RECHARGEABLE_AREA = '358' 195 | NO_CHARGING_SPOT_INFO = '359' 196 | EV_RANGE = '360' 197 | CLIMATE_ON_OFF = '366' 198 | ONLINE_FUEL_STATION_INFO = '367' 199 | DESTINATION_SEND_TO_CAR = '37' 200 | ECALL = '4' 201 | GOOGLE_PLACES_SEARCH = '40' 202 | PREMIUM_TRAFFIC = '43' 203 | AUTO_COLLISION_NOTIFICATION_ACN = '6' 204 | THEFT_BURGLAR_NOTIFICATION_VEHICLE = '7' 205 | ECO_CHALLENGE = '721' 206 | ECO_CHALLENGE_FLEET = '722' 207 | MOBILE_INFORMATION = '74' 208 | URL_PRESET_ON_VEHICLE = '77' 209 | ASSISTED_DESTINATION_SETTING = '78' 210 | CONCIERGE = '79' 211 | PERSONAL_DATA_SYNC = '80' 212 | THEFT_BURGLAR_NOTIFICATION_APP = '87' 213 | STOLEN_VEHICLE_TRACKING_SVT = '9' 214 | REMOTE_ENGINE_START = '96' 215 | HORN_AND_LIGHTS = '97' 216 | CURFEW_ALERT = '98' 217 | TEMPERATURE = '2042' 218 | VALET_PARKING_CALL = '401' 219 | PANIC_CALL = '406' 220 | 221 | 222 | class Language(enum.Enum): 223 | """The service requires ISO 639-1 language codes to be mapped back 224 | to ISO 3166-1 country codes. Of course. 225 | """ 226 | 227 | # Bulgarian = Bulgaria 228 | BG = 'BG' 229 | # Czech = Czech Republic 230 | CS = 'CZ' 231 | # Danish = Denmark 232 | DA = 'DK' 233 | # German = Germany 234 | DE = 'DE' 235 | # Greek = Greece 236 | EL = 'GR' 237 | # Spanish = Spain 238 | ES = 'ES' 239 | # Finnish = Finland 240 | FI = 'FI' 241 | # French = France 242 | FR = 'FR' 243 | # Hebrew = Israel 244 | HE = 'IL' 245 | # Croatian = Croatia 246 | HR = 'HR' 247 | # Hungarian = Hungary 248 | HU = 'HU' 249 | # Italian = Italy 250 | IT = 'IT' 251 | # Formal Norwegian = Norway 252 | NB = 'NO' 253 | # Dutch = Netherlands 254 | NL = 'NL' 255 | # Polish = Poland 256 | PL = 'PL' 257 | # Portuguese = Portugal 258 | PT = 'PT' 259 | # Romanian = Romania 260 | RO = 'RO' 261 | # Russian = Russia 262 | RU = 'RU' 263 | # Slovakian = Slovakia 264 | SK = 'SK' 265 | # Slovenian = Slovenia 266 | SI = 'SL' 267 | # Serbian = Serbia 268 | SR = 'RS' 269 | # Swedish = Sweden 270 | SV = 'SE' 271 | # Ukranian = Ukraine 272 | UK = 'UA' 273 | # Default 274 | EN = 'EN' 275 | 276 | 277 | class Order(enum.Enum): 278 | DESC = 'DESC' 279 | ASC = 'ASC' 280 | 281 | 282 | class NotificationCategoryKey(enum.Enum): 283 | ASSISTANCE = 'assistance' 284 | CHARGE_EV = 'chargeev' 285 | CUSTOM = 'custom' 286 | EV_BATTERY = 'EVBattery' 287 | FOTA = 'fota' 288 | GEO_FENCING = 'geofencing' 289 | MAINTENANCE = 'maintenance' 290 | NAVIGATION = 'navigation' 291 | PRIVACY_MODE = 'privacymode' 292 | REMOTE_CONTROL = 'remotecontrol' 293 | RESET = 'RESET' 294 | RGDC = 'rgdcmyze' 295 | SAFETY_AND_SECURITY = 'Safety&Security' 296 | SVT = 'SVT' 297 | 298 | 299 | class NotificationStatus(enum.Enum): 300 | READ = 'READ' 301 | UNREAD = 'UNREAD' 302 | 303 | 304 | class NotificationChannelType(enum.Enum): 305 | PUSH_APP = 'PUSH_APP' 306 | MAIL = 'MAIL' 307 | OFF = '' 308 | SMS = 'SMS' 309 | 310 | 311 | NotificationType = collections.namedtuple('NotificationType', ['key', 'title', 'message', 'category']) 312 | NotificationCategory = collections.namedtuple('Category', ['key', 'title']) 313 | 314 | 315 | class NotificationTypeKey(enum.Enum): 316 | ABS_ALERT = 'abs.alert' 317 | AVAILABLE_CHARGING = 'available.charging' 318 | BADGE_BATTERY_ALERT = 'badge.battery.alert' 319 | BATTERY_BLOWING_REQUEST = 'battery.blowing.request' 320 | BATTERY_CHARGE_AVAILABLE = 'battery.charge.available' 321 | BATTERY_CHARGE_IN_PROGRESS = 'battery.charge.in.progress' 322 | BATTERY_CHARGE_UNAVAILABLE = 'battery.charge.unavailable' 323 | BATTERY_COOLING_CONDITIONNING_REQUEST = 'battery.cooling.conditionning.request' 324 | BATTERY_ENDED_CHARGE = 'battery.ended.charge' 325 | BATTERY_FLAP_OPENED = 'battery.flap.opened' 326 | BATTERY_FULL_EXCEPTION = 'battery.full.exception' 327 | BATTERY_HEATING_CONDITIONNING_REQUEST = 'battery.heating.conditionning.request' 328 | BATTERY_HEATING_START = 'battery.heating.start' 329 | BATTERY_HEATING_STOP = 'battery.heating.stop' 330 | BATTERY_PREHEATING_START = 'battery.preheating.start' 331 | BATTERY_PREHEATING_STOP = 'battery.preheating.stop' 332 | BATTERY_SCHEDULE_ISSUE = 'battery.schedule.issue' 333 | BATTERY_TEMPERATURE_ALERT = 'battery.temperature.alert' 334 | BATTERY_WAITING_CURRENT_CHARGE = 'battery.waiting.current.charge' 335 | BATTERY_WAITING_PLANNED_CHARGE = 'battery.waiting.planned.charge' 336 | BRAKE_ALERT = 'brake.alert' 337 | BRAKE_SYSTEM_MALFUNCTION = 'brake.system.malfunction' 338 | BURGLAR_ALARM_LOST = 'burglar.alarm.lost' 339 | BURGLAR_CAR_STOLEN = 'burglar.car.stolen' 340 | BURGLAR_TOW_INFO = 'burglar.tow.info' 341 | CHARGE_FAILURE = 'charge.failure' 342 | CHARGE_NOT_PROHIBITED = 'charge.not.prohibited' 343 | CHARGE_PROHIBITED = 'charge.prohibited' 344 | CHARGING_STOP_GEN3 = 'charging.stop.gen3' 345 | COOLANT_ALERT = 'coolant.alert' 346 | CRASH_DETECTION_ALERT = 'crash.detection.alert' 347 | CURFEW_INFRINGEMENT = 'curfew.infringement' 348 | CURFEW_RECOVERY = 'curfew.recovery' 349 | CUSTOM = 'custom' 350 | DURING_INHIBITED_CHARGING = 'during.inhibited.charging' 351 | ENGINE_WATER_TEMP_ALERT = 'engine.water.temp.alert' 352 | EPS_ALERT = 'eps.alert' 353 | FOTA_CAMPAIGN_AVAILABLE = 'fota.campaign.available' 354 | FOTA_CAMPAIGN_STATUS_ACTIVATION_COMPLETED = 'fota.campaign.status.activation.completed' 355 | FOTA_CAMPAIGN_STATUS_ACTIVATION_FAILED = 'fota.campaign.status.activation.failed' 356 | FOTA_CAMPAIGN_STATUS_ACTIVATION_POSTPONED = 'fota.campaign.status.activation.postponed' 357 | FOTA_CAMPAIGN_STATUS_ACTIVATION_PROGRESS = 'fota.campaign.status.activation.progress' 358 | FOTA_CAMPAIGN_STATUS_ACTIVATION_SCHEDULED = 'fota.campaign.status.activation.scheduled' 359 | FOTA_CAMPAIGN_STATUS_CANCELLED = 'fota.campaign.status.cancelled' 360 | FOTA_CAMPAIGN_STATUS_CANCELLING = 'fota.campaign.status.cancelling' 361 | FOTA_CAMPAIGN_STATUS_DOWNLOAD_COMPLETED = 'fota.campaign.status.download.completed' 362 | FOTA_CAMPAIGN_STATUS_DOWNLOAD_PAUSED = 'fota.campaign.status.download.paused' 363 | FOTA_CAMPAIGN_STATUS_DOWNLOAD_PROGRESS = 'fota.campaign.status.download.progress' 364 | FOTA_CAMPAIGN_STATUS_INSTALLATION_COMPLETED = 'fota.campaign.status.installation.completed' 365 | FOTA_CAMPAIGN_STATUS_INSTALLATION_PROGRESS = 'fota.campaign.status.installation.progress' 366 | FUEL_ALERT = 'fuel.alert' 367 | HVAC_AUTOSTART = 'hvac.autostart' 368 | HVAC_AUTOSTOP = 'hvac.autostop' 369 | HVAC_TECHNICAL_ISSUE = 'hvac.technical.issue' 370 | HVAC_TRACTION_BATTERY_LOW = 'hvac.traction.battery.low' 371 | HVAC_VEHICLE_IN_USE = 'hvac.vehicle.in.use' 372 | HVAC_VEHICLE_NOT_CONNECTED_POWER = 'hvac.vehicle.not.connected.power' 373 | LAST_MILE_DESTINATION_ADDRESS = 'last.mile.destination.address' 374 | LOCK_STATUS_REMINDER = 'lock.status.reminder' 375 | MAINTENANCE_DISTANCE_PREALERT = 'maintenance.distance.prealert' 376 | MAINTENANCE_TIME_PREALERT = 'maintenance.time.prealert' 377 | MIL_LAMP_AUTO_TEST = 'mil.lamp.auto.test' 378 | MIL_LAMP_FLASH_REQUEST = 'mil.lamp.flash.request' 379 | MIL_LAMP_OFF_REQUEST = 'mil.lamp.off.request' 380 | MIL_LAMP_ON_REQUEST = 'mil.lamp.on.request' 381 | NEXT_CHARGING_INHIBITED = 'next.charging.inhibited' 382 | OIL_LEVEL_ALERT = 'oil.level.alert' 383 | OIL_PRESSURE_ALERT = 'oil.pressure.alert' 384 | OUT_OF_PARK_POSITION_CHARGE_INTERRUPTION = 'out.of.park.position.charge.interruption' 385 | PLUG_CONNECTION_ISSUE = 'plug.connection.issue' 386 | PLUG_CONNECTION_SUCCESS = 'plug.connection.success' 387 | PLUG_UNLOCKING = 'plug.unlocking' 388 | PREREMINDER_ALERT_DEFAULT = 'prereminder.alert.default' 389 | PRIVACY_MODE_OFF = 'privacy.mode.off' 390 | PRIVACY_MODE_ON = 'privacy.mode.on' 391 | PROGRAMMED_CHARGE_INTERRUPTION = 'programmed.charge.interruption' 392 | PROHIBITION_BATTERY_RENTAL = 'prohibition.battery.rental' 393 | PWT_START_IMPOSSIBLE = 'pwt.start.impossible' 394 | REMOTE_LEFT_TIME_CYCLE = 'remote.left.time.cycle' 395 | REMOTE_START_CUSTOMER = 'remote.start.customer' 396 | REMOTE_START_ENGINE = 'remote.start.engine' 397 | REMOTE_START_NORMAL_ONLY = 'remote.start.normal.only' 398 | REMOTE_START_PHONE_ERROR = 'remote.start.phone.error' 399 | REMOTE_START_UNAVAILABLE = 'remote.start.unavailable' 400 | REMOTE_START_WAIT_PRESOAK = 'remote.start.wait.presoak' 401 | SERV_WARNING_ALERT = 'serv.warning.alert' 402 | SPEED_INFRINGEMENT = 'speed.infringement' 403 | SPEED_RECOVERY = 'speed.recovery' 404 | START_DRIVING_CHARGE_INTERRUPTION = 'start.driving.charge.interruption' 405 | START_IN_PROGRESS = 'start.in.progress' 406 | STATUS_OIL_PRESSURE_SWITCH_CLOSED = 'status.oil.pressure.switch.closed' 407 | STATUS_OIL_PRESSURE_SWITCH_OPEN = 'status.oil.pressure.switch.open' 408 | STOP_WARNING_ALERT = 'stop.warning.alert' 409 | UNPLUG_CHARGE = 'unplug.charge' 410 | WAITING_PLANNED_CHARGE = 'waiting.planned.charge' 411 | WHEEL_ALERT = 'wheel.alert' 412 | ZONE_INFRINGEMENT = 'zone.infringement' 413 | ZONE_RECOVERY = 'zone.recovery' 414 | 415 | 416 | class NotificationRuleKey(enum.Enum): 417 | ABS_ALERT = 'abs.alert' 418 | AVAILABLE_CHARGING = 'available.charging' 419 | BADGE_BATTERY_ALERT = 'badge.battery.alert' 420 | BATTERY_BLOWING_REQUEST = 'battery.blowing.request' 421 | BATTERY_CHARGE_AVAILABLE = 'battery.charge.available' 422 | BATTERY_CHARGE_IN_PROGRESS = 'battery.charge.in.progress' 423 | BATTERY_CHARGE_UNAVAILABLE = 'battery.charge.unavailable' 424 | BATTERY_COOLING_CONDITIONNING_REQUEST = 'battery.cooling.conditionning.request' 425 | BATTERY_ENDED_CHARGE = 'battery.ended.charge' 426 | BATTERY_FLAP_OPENED = 'battery.flap.opened' 427 | BATTERY_FULL_EXCEPTION = 'battery.full.exception' 428 | BATTERY_HEATING_CONDITIONNING_REQUEST = 'battery.heating.conditionning.request' 429 | BATTERY_HEATING_START = 'battery.heating.start' 430 | BATTERY_HEATING_STOP = 'battery.heating.stop' 431 | BATTERY_PREHEATING_START = 'battery.preheating.start' 432 | BATTERY_PREHEATING_STOP = 'battery.preheating.stop' 433 | BATTERY_SCHEDULE_ISSUE = 'battery.schedule.issue' 434 | BATTERY_TEMPERATURE_ALERT = 'battery.temperature.alert' 435 | BATTERY_WAITING_CURRENT_CHARGE = 'battery.waiting.current.charge' 436 | BATTERY_WAITING_PLANNED_CHARGE = 'battery.waiting.planned.charge' 437 | BRAKE_ALERT = 'brake.alert' 438 | BRAKE_SYSTEM_MALFUNCTION = 'brake.system.malfunction' 439 | BURGLAR_ALARM_LOST = 'burglar.alarm.lost' 440 | BURGLAR_CAR_STOLEN = 'burglar.car.stolen' 441 | BURGLAR_TOW_INFO = 'burglar.tow.info' 442 | BURGLAR_TOW_SYSTEM_FAILURE = 'burglar.tow.system.failure' 443 | CHARGE_FAILURE = 'charge.failure' 444 | CHARGE_NOT_PROHIBITED = 'charge.not.prohibited' 445 | CHARGE_PROHIBITED = 'charge.prohibited' 446 | CHARGING_STOP_GEN3 = 'charging.stop.gen3' 447 | COOLANT_ALERT = 'coolant.alert' 448 | CRASH_DETECTION_ALERT = 'crash.detection.alert' 449 | CURFEW_INFRINGEMENT = 'curfew.infringement' 450 | CURFEW_RECOVERY = 'curfew.recovery' 451 | CUSTOM = 'custom' 452 | DURING_INHIBITED_CHARGING = 'during.inhibited.charging' 453 | ENGINE_WATER_TEMP_ALERT = 'engine.water.temp.alert' 454 | EPS_ALERT = 'eps.alert' 455 | FOTA_CAMPAIGN_AVAILABLE = 'fota.campaign.available' 456 | FOTA_CAMPAIGN_STATUS_ACTIVATION_COMPLETED = 'fota.campaign.status.activation.completed' 457 | FOTA_CAMPAIGN_STATUS_ACTIVATION_FAILED = 'fota.campaign.status.activation.failed' 458 | FOTA_CAMPAIGN_STATUS_ACTIVATION_POSTPONED = 'fota.campaign.status.activation.postponed' 459 | FOTA_CAMPAIGN_STATUS_ACTIVATION_PROGRESS = 'fota.campaign.status.activation.progress' 460 | FOTA_CAMPAIGN_STATUS_ACTIVATION_SCHEDULED = 'fota.campaign.status.activation.scheduled' 461 | FOTA_CAMPAIGN_STATUS_CANCELLED = 'fota.campaign.status.cancelled' 462 | FOTA_CAMPAIGN_STATUS_CANCELLING = 'fota.campaign.status.cancelling' 463 | FOTA_CAMPAIGN_STATUS_DOWNLOAD_COMPLETED = 'fota.campaign.status.download.completed' 464 | FOTA_CAMPAIGN_STATUS_DOWNLOAD_PAUSED = 'fota.campaign.status.download.paused' 465 | FOTA_CAMPAIGN_STATUS_DOWNLOAD_PROGRESS = 'fota.campaign.status.download.progress' 466 | FOTA_CAMPAIGN_STATUS_INSTALLATION_COMPLETED = 'fota.campaign.status.installation.completed' 467 | FOTA_CAMPAIGN_STATUS_INSTALLATION_PROGRESS = 'fota.campaign.status.installation.progress' 468 | FUEL_ALERT = 'fuel.alert' 469 | HVAC_AUTOSTART = 'hvac.autostart' 470 | HVAC_AUTOSTOP = 'hvac.autostop' 471 | HVAC_TECHNICAL_ISSUE = 'hvac.technical.issue' 472 | HVAC_TRACTION_BATTERY_LOW = 'hvac.traction.battery.low' 473 | HVAC_VEHICLE_IN_USE = 'hvac.vehicle.in.use' 474 | HVAC_VEHICLE_NOT_CONNECTED_POWER = 'hvac.vehicle.not.connected.power' 475 | LAST_MILE_DESTINATION_ADDRESS = 'last.mile.destination.address' 476 | LOCK_STATUS_REMINDER = 'lock.status.reminder' 477 | MAINTENANCE_DISTANCE_PREALERT = 'maintenance.distance.prealert' 478 | MAINTENANCE_TIME_PREALERT = 'maintenance.time.prealert' 479 | MIL_LAMP_AUTO_TEST = 'mil.lamp.auto.test' 480 | MIL_LAMP_FLASH_REQUEST = 'mil.lamp.flash.request' 481 | MIL_LAMP_OFF_REQUEST = 'mil.lamp.off.request' 482 | MIL_LAMP_ON_REQUEST = 'mil.lamp.on.request' 483 | NEXT_CHARGING_INHIBITED = 'next.charging.inhibited' 484 | OIL_LEVEL_ALERT = 'oil.level.alert' 485 | OIL_PRESSURE_ALERT = 'oil.pressure.alert' 486 | OUT_OF_PARK_POSITION_CHARGE_INTERRUPTION = 'out.of.park.position.charge.interruption' 487 | PLUG_CONNECTION_ISSUE = 'plug.connection.issue' 488 | PLUG_CONNECTION_SUCCESS = 'plug.connection.success' 489 | PLUG_UNLOCKING = 'plug.unlocking' 490 | PREREMINDER_ALERT_DEFAULT = 'prereminder.alert.default' 491 | PRIVACY_MODE_OFF = 'privacy.mode.off' 492 | PRIVACY_MODE_ON = 'privacy.mode.on' 493 | PROGRAMMED_CHARGE_INTERRUPTION = 'programmed.charge.interruption' 494 | PROHIBITION_BATTERY_RENTAL = 'prohibition.battery.rental' 495 | PWT_START_IMPOSSIBLE = 'pwt.start.impossible' 496 | REMOTE_LEFT_TIME_CYCLE = 'remote.left.time.cycle' 497 | REMOTE_START_CUSTOMER = 'remote.start.customer' 498 | REMOTE_START_ENGINE = 'remote.start.engine' 499 | REMOTE_START_NORMAL_ONLY = 'remote.start.normal.only' 500 | REMOTE_START_PHONE_ERROR = 'remote.start.phone.error' 501 | REMOTE_START_UNAVAILABLE = 'remote.start.unavailable' 502 | REMOTE_START_WAIT_PRESOAK = 'remote.start.wait.presoak' 503 | RENAULT_RESET_FACTORY = 'renault.reset.factory' 504 | RGDC_CHARGE_COMPLETE = 'rgdc.charge.complete' 505 | RGDC_CHARGE_ERROR = 'rgdc.charge.error' 506 | RGDC_CHARGE_ON = 'rgdc.charge.on' 507 | RGDC_CHARGE_STATUS = 'rgdc.charge.status' 508 | RGDC_LOW_BATTERY_ALERT = 'rgdc.low.battery.alert' 509 | RGDC_LOW_BATTERY_REMINDER = 'rgdc.low.battery.reminder' 510 | SERV_WARNING_ALERT = 'serv.warning.alert' 511 | SPEED_INFRINGEMENT = 'speed.infringement' 512 | SPEED_RECOVERY = 'speed.recovery' 513 | SRP_PINCODE_ACKNOWLEDGEMENT = 'srp.pincode.acknowledgement' 514 | SRP_PINCODE_DELETION = 'srp.pincode.deletion' 515 | SRP_PINCODE_STATUS = 'srp.pincode.status' 516 | SRP_SALT_REQUEST = 'srp.salt.request' 517 | START_DRIVING_CHARGE_INTERRUPTION = 'start.driving.charge.interruption' 518 | START_IN_PROGRESS = 'start.in.progress' 519 | STATUS_OIL_PRESSURE_SWITCH_CLOSED = 'status.oil.pressure.switch.closed' 520 | STATUS_OIL_PRESSURE_SWITCH_OPEN = 'status.oil.pressure.switch.open' 521 | STOLEN_VEHICLE_TRACKING = 'stolen.vehicle.tracking' 522 | STOLEN_VEHICLE_TRACKING_BLOCKING = 'stolen.vehicle.tracking.blocking' 523 | STOP_WARNING_ALERT = 'stop.warning.alert' 524 | SVT_SERVICE_ACTIVATION = 'svt.service.activation' 525 | UNPLUG_CHARGE = 'unplug.charge' 526 | WAITING_PLANNED_CHARGE = 'waiting.planned.charge' 527 | WHEEL_ALERT = 'wheel.alert' 528 | ZONE_INFRINGEMENT = 'zone.infringement' 529 | ZONE_RECOVERY = 'zone.recovery' 530 | 531 | 532 | class NotificationPriority(enum.Enum): 533 | 534 | NONE = None 535 | P0 = 0 536 | P1 = 1 537 | P2 = 2 538 | P3 = 3 539 | 540 | 541 | class NotificationRuleStatus(enum.Enum): 542 | ACTIVATED = 'ACTIVATED' 543 | ACTIVATION_IN_PROGRESS = 'STATUS_ACTIVATION_IN_PROGRESS' 544 | DELETION_IN_PROGRESS = 'STATUS_DELETION_IN_PROGRESS' 545 | 546 | 547 | class Notification: 548 | 549 | @property 550 | def vehicle(self): 551 | return _registry[VEHICLES][self.vin] 552 | 553 | @property 554 | def user_id(self): 555 | return self.vehicle.user_id 556 | 557 | @property 558 | def session(self): 559 | return self.vehicle.session 560 | 561 | def __init__(self, data, language, vin): 562 | self.language = language 563 | self.vin = vin 564 | self.id = data['notificationId'] 565 | self.title = data['messageTitle'] 566 | self.subtitle = data['messageSubtitle'] 567 | self.description = data['messageDescription'] 568 | self.category = NotificationCategoryKey(data['categoryKey']) 569 | self.rule_key = NotificationRuleKey(data['ruleKey']) 570 | self.notification_key = NotificationTypeKey(data['notificationKey']) 571 | self.priority = NotificationPriority(data['priority']) 572 | self.state = NotificationStatus(data['status']) 573 | t = datetime.datetime.strptime(data['timestamp'].split('.')[0], '%Y-%m-%dT%H:%M:%S') 574 | if '.' in data['timestamp']: 575 | fraction = data['timestamp'][20:-1] 576 | t = t.replace(microsecond=int(fraction) * 10**(6-len(fraction))) 577 | self.time = t 578 | # List of {'name': 'N', 'type': 'T', 'value': 'V'} 579 | self.data = data['data'] 580 | # future use maybe? empty dict 581 | self.metadata = data['metadata'] 582 | 583 | def __str__(self): 584 | # title is kinda useless, subtitle has better content 585 | return '{}: {}'.format(self.time, self.subtitle) 586 | 587 | def fetch_details(self, language: Language=None): 588 | if language is None: 589 | language = self.language 590 | resp = self.session.oauth.get( 591 | '{}v2/notifications/users/{}/vehicles/{}/notifications/{}'.format( 592 | self.session.settings['notifications_base_url'], 593 | self.user_id, self.vin, self.id 594 | ), 595 | params={'langCode': language.value} 596 | ) 597 | return resp 598 | 599 | 600 | class KamereonSession: 601 | 602 | tenant = None 603 | copy_realm = None 604 | 605 | def __init__(self, region, session=None): 606 | self.settings = settings_map[self.tenant][region] 607 | if session is None: 608 | session = requests.session() 609 | self.session = session 610 | self._oauth = None 611 | self._user_id = None 612 | # ugly hack 613 | os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' 614 | 615 | def login(self, username, password): 616 | # grab an auth ID to use as part of the username/password login request, 617 | # then move to the regular OAuth2 process 618 | 619 | auth_url = '{}json/realms/root/realms/{}/authenticate'.format( 620 | self.settings['auth_base_url'], 621 | self.settings['realm'], 622 | ) 623 | resp = self.session.post( 624 | auth_url, 625 | headers={ 626 | 'Accept-Api-Version': API_VERSION, 627 | 'X-Username': 'anonymous', 628 | 'X-Password': 'anonymous', 629 | 'Accept': 'application/json', 630 | }) 631 | next_body = resp.json() 632 | 633 | # insert the username, and password 634 | for c in next_body['callbacks']: 635 | input_type = c['type'] 636 | if input_type == 'NameCallback': 637 | c['input'][0]['value'] = username 638 | elif input_type == 'PasswordCallback': 639 | c['input'][0]['value'] = password 640 | 641 | resp = self.session.post( 642 | auth_url, 643 | headers={ 644 | 'Accept-Api-Version': API_VERSION, 645 | 'X-Username': 'anonymous', 646 | 'X-Password': 'anonymous', 647 | 'Accept': 'application/json', 648 | 'Content-Type': 'application/json', 649 | }, 650 | data=json.dumps(next_body)) 651 | 652 | oauth_data = resp.json() 653 | 654 | oauth_authorize_url = '{}oauth2{}/authorize'.format( 655 | self.settings['auth_base_url'], 656 | oauth_data['realm'] 657 | ) 658 | nonce = generate_nonce() 659 | resp = self.session.get( 660 | oauth_authorize_url, 661 | params={ 662 | 'client_id': self.settings['client_id'], 663 | 'redirect_uri': self.settings['redirect_uri'], 664 | 'response_type': 'code', 665 | 'scope': self.settings['scope'], 666 | 'nonce': nonce, 667 | }, 668 | allow_redirects=False) 669 | oauth_authorize_url = resp.headers['location'] 670 | 671 | oauth_token_url = '{}oauth2{}/access_token'.format( 672 | self.settings['auth_base_url'], 673 | oauth_data['realm'] 674 | ) 675 | self._oauth = OAuth2Session( 676 | client_id=self.settings['client_id'], 677 | redirect_uri=self.settings['redirect_uri'], 678 | scope=self.settings['scope']) 679 | self._oauth._client.nonce = nonce 680 | self._oauth.fetch_token( 681 | oauth_token_url, 682 | authorization_response=oauth_authorize_url, 683 | client_secret=self.settings['client_secret'], 684 | include_client_id=True) 685 | 686 | @property 687 | def oauth(self): 688 | if self._oauth is None: 689 | raise RuntimeError('No access token set, you need to log in first.') 690 | return self._oauth 691 | 692 | @property 693 | def user_id(self): 694 | if not self._user_id: 695 | resp = self.oauth.get( 696 | '{}v1/users/current'.format(self.settings['user_adapter_base_url']) 697 | ) 698 | self._user_id = resp.json()['userId'] 699 | _registry[USERS][self._user_id] = self 700 | return self._user_id 701 | 702 | def fetch_vehicles(self): 703 | resp = self.oauth.get( 704 | '{}v2/users/{}/cars'.format(self.settings['user_base_url'], self.user_id) 705 | ) 706 | vehicles = [] 707 | for vehicle_data in resp.json()['data']: 708 | vehicle = Vehicle(vehicle_data, self.user_id) 709 | vehicles.append(vehicle) 710 | _registry[VEHICLES][vehicle.vin] = vehicle 711 | return vehicles 712 | 713 | 714 | class NCISession(KamereonSession): 715 | 716 | tenant = 'nissan' 717 | copy_realm = 'P_NCB' 718 | 719 | 720 | class Vehicle: 721 | 722 | def __repr__(self): 723 | return '<{} {}>'.format(self.__class__.__name__, self.vin) 724 | 725 | def __str__(self): 726 | return self.nickname or self.vin 727 | 728 | @property 729 | def session(self): 730 | return _registry[USERS][self.user_id] 731 | 732 | def __init__(self, data, user_id): 733 | self.user_id = user_id 734 | self.vin = data['vin'].upper() 735 | self.features = [ 736 | Feature(u['name']) 737 | for u in data.get('uids', []) 738 | if u['enabled']] 739 | self.can_generation = data.get('canGeneration') 740 | self.color = data.get('color') 741 | self.energy = data.get('energy') 742 | self.vehicle_gateway = data.get('carGateway') 743 | self.battery_code = data.get('batteryCode') 744 | self.engine_type = data.get('engineType') 745 | self.first_registration_date = data.get('firstRegistrationDate') 746 | self.ice_or_ev = data.get('iceEvFlag') 747 | self.model_name = data.get('modelName') 748 | self.nickname = data.get('nickname') 749 | self.phase = data.get('phase') 750 | self.picture_url = data.get('pictureURL') 751 | self.privacy_mode = data.get('privacyMode') 752 | self.registration_number = data.get('registrationNumber') 753 | self.battery_capacity = None 754 | self.battery_level = None 755 | self.battery_temperature = None 756 | self.battery_bar_level = None 757 | self.instantaneous_power = None 758 | self.charging_speed = None 759 | self.charge_time_required_to_full = { 760 | ChargingSpeed.FAST: None, 761 | ChargingSpeed.NORMAL: None, 762 | ChargingSpeed.SLOW: None, 763 | } 764 | self.range_hvac_off = None 765 | self.range_hvac_on = None 766 | self.charging = ChargingStatus.NOT_CHARGING 767 | self.plugged_in = PluggedStatus.NOT_PLUGGED 768 | self.plugged_in_time = None 769 | self.unplugged_time = None 770 | self.battery_status_last_updated = None 771 | self.location = None 772 | self.location_last_updated = None 773 | self.combustion_fuel_unit_cost = None 774 | self.electricity_unit_cost = None 775 | self.external_temperature = None 776 | self.internal_temperature = None 777 | self.hvac_status = None 778 | self.next_hvac_start_date = None 779 | self.next_target_temperature = None 780 | self.hvac_status_last_updated = None 781 | self.door_status = { 782 | Door.FRONT_LEFT: None, 783 | Door.FRONT_RIGHT: None, 784 | Door.REAR_LEFT: None, 785 | Door.REAR_RIGHT: None, 786 | Door.HATCH: None 787 | } 788 | self.lock_status = None 789 | self.lock_status_last_updated = None 790 | self.eco_score = None 791 | self.fuel_autonomy = None 792 | self.fuel_consumption = None 793 | self.fuel_economy = None 794 | self.fuel_level = None 795 | self.fuel_low_warning = None 796 | self.fuel_quantity = None 797 | self.mileage = None 798 | self.total_mileage = None 799 | 800 | def refresh(self): 801 | self.refresh_location() 802 | self.refresh_battery_status() 803 | self.fetch_all() 804 | 805 | def fetch_all(self): 806 | self.fetch_cockpit() 807 | self.fetch_location() 808 | self.fetch_battery_status() 809 | self.fetch_energy_unit_cost() 810 | self.fetch_hvac_status() 811 | self.fetch_lock_status() 812 | 813 | def refresh_location(self): 814 | resp = self.session.oauth.post( 815 | '{}v1/cars/{}/actions/refresh-location'.format(self.session.settings['car_adapter_base_url'], self.vin), 816 | data=json.dumps({ 817 | 'data': {'type': 'RefreshLocation'} 818 | }), 819 | headers={'Content-Type': 'application/vnd.api+json'} 820 | ) 821 | body = resp.json() 822 | if 'errors' in body: 823 | raise ValueError(body['errors']) 824 | return body 825 | 826 | def fetch_location(self): 827 | resp = self.session.oauth.get( 828 | '{}v1/cars/{}/location'.format(self.session.settings['car_adapter_base_url'], self.vin), 829 | headers={'Content-Type': 'application/vnd.api+json'} 830 | ) 831 | body = resp.json() 832 | if 'errors' in body: 833 | raise ValueError(body['errors']) 834 | location_data = body['data']['attributes'] 835 | self.location = (location_data['gpsLatitude'], location_data['gpsLongitude']) 836 | self.location_last_updated = datetime.datetime.fromisoformat(location_data['lastUpdateTime'].replace('Z','+00:00')) 837 | 838 | def refresh_lock_status(self): 839 | resp = self.session.oauth.post( 840 | '{}v1/cars/{}/actions/refresh-lock-status'.format(self.session.settings['car_adapter_base_url'], self.vin), 841 | data=json.dumps({ 842 | 'data': {'type': 'RefreshLockStatus'} 843 | }), 844 | headers={'Content-Type': 'application/vnd.api+json'} 845 | ) 846 | body = resp.json() 847 | if 'errors' in body: 848 | raise ValueError(body['errors']) 849 | return body 850 | 851 | def fetch_lock_status(self): 852 | if Feature.LOCK_STATUS_CHECK not in self.features: 853 | return 854 | resp = self.session.oauth.get( 855 | '{}v1/cars/{}/lock-status'.format(self.session.settings['car_adapter_base_url'], self.vin), 856 | headers={'Content-Type': 'application/vnd.api+json'} 857 | ) 858 | body = resp.json() 859 | if 'errors' in body: 860 | raise ValueError(body['errors']) 861 | lock_data = body['data']['attributes'] 862 | self.door_status[Door.FRONT_LEFT] = LockStatus(lock_data['doorStatusFrontLeft']) 863 | self.door_status[Door.FRONT_RIGHT] = LockStatus(lock_data['doorStatusFrontRight']) 864 | self.door_status[Door.REAR_LEFT] = LockStatus(lock_data['doorStatusRearLeft']) 865 | self.door_status[Door.REAR_RIGHT] = LockStatus(lock_data['doorStatusRearRight']) 866 | self.door_status[Door.HATCH] = LockStatus(lock_data['hatchStatus']) 867 | self.lock_status = LockStatus(lock_data['lockStatus']) 868 | self.lock_status_last_updated = datetime.datetime.fromisoformat(lock_data['lastUpdateTime'].replace('Z','+00:00')) 869 | 870 | def refresh_hvac_status(self): 871 | resp = self.session.oauth.post( 872 | '{}v1/cars/{}/actions/refresh-hvac-status'.format(self.session.settings['car_adapter_base_url'], self.vin), 873 | data=json.dumps({ 874 | 'data': {'type': 'RefreshHvacStatus'} 875 | }), 876 | headers={'Content-Type': 'application/vnd.api+json'} 877 | ) 878 | body = resp.json() 879 | if 'errors' in body: 880 | raise ValueError(body['errors']) 881 | return body 882 | 883 | def initiate_srp(self): 884 | (salt, verifier) = SRP.enroll(self.user_id, self.vin) 885 | resp = self.session.oauth.post( 886 | '{}v1/cars/{}/actions/srp-initiates'.format(self.session.settings['car_adapter_base_url'], self.vin), 887 | data=json.dumps({ 888 | "data": { 889 | "type": "SrpInitiates", 890 | "attributes": { 891 | "s": salt, 892 | "i": self.user_id, 893 | "v": verifier 894 | } 895 | } 896 | }), 897 | headers={'Content-Type': 'application/vnd.api+json'} 898 | ) 899 | body = resp.json() 900 | if 'errors' in body: 901 | raise ValueError(body['errors']) 902 | return body 903 | 904 | def validate_srp(self): 905 | a = SRP.generate_a() 906 | resp = self.session.oauth.post( 907 | '{}v1/cars/{}/actions/srp-sets'.format(self.session.settings['car_adapter_base_url'], self.vin), 908 | data=json.dumps({ 909 | "data": { 910 | "type": "SrpSets", 911 | "attributes": { 912 | "i": self.user_id, 913 | "a": a 914 | } 915 | } 916 | }), 917 | headers={'Content-Type': 'application/vnd.api+json'} 918 | ) 919 | body = resp.json() 920 | if 'errors' in body: 921 | raise ValueError(body['errors']) 922 | return body 923 | 924 | """ 925 | Other vehicle controls to implement / investigate: 926 | DataReset 927 | DeleteCurfewRestrictions 928 | CreateCurfewRestrictions 929 | CreateSpeedRestrictions 930 | SrpInitiates 931 | DeleteAreaRestrictions 932 | SrpDelete 933 | SrpSets 934 | OpenClose 935 | EngineStart 936 | LockUnlock 937 | CreateAreaRestrictions 938 | DeleteSpeedRestrictions 939 | """ 940 | 941 | def control_charging(self, action: str, srp: str=None): 942 | assert action in ('stop', 'start') 943 | if action == 'start' and Feature.CHARGING_START not in self.features: 944 | return 945 | if action == 'stop' and Feature.CHARGING_STOP not in self.features: 946 | return 947 | attributes = { 948 | 'action': action, 949 | } 950 | if srp is not None: 951 | attributes['srp'] = srp 952 | resp = self.session.oauth.post( 953 | '{}v1/cars/{}/actions/charging-start'.format(self.session.settings['car_adapter_base_url'], self.vin), 954 | data=json.dumps({ 955 | 'data': { 956 | 'type': 'ChargingStart', 957 | 'attributes': attributes 958 | } 959 | }), 960 | headers={'Content-Type': 'application/vnd.api+json'} 961 | ) 962 | body = resp.json() 963 | if 'errors' in body: 964 | raise ValueError(body['errors']) 965 | return body 966 | 967 | def control_horn_lights(self, action: str, target: str, duration: int=5, srp: str=None): 968 | if Feature.HORN_AND_LIGHTS not in self.features: 969 | return 970 | assert target in ('horn_lights', 'lights', 'horn') 971 | assert action in ('stop', 'start', 'double_start') 972 | attributes = { 973 | 'action': action, 974 | 'duration': duration, 975 | 'target': target, 976 | } 977 | if srp is not None: 978 | attributes['srp'] = srp 979 | resp = self.session.oauth.post( 980 | '{}v1/cars/{}/actions/horn-lights'.format(self.session.settings['car_adapter_base_url'], self.vin), 981 | data=json.dumps({ 982 | 'data': { 983 | 'type': 'HornLights', 984 | 'attributes': attributes 985 | } 986 | }), 987 | headers={'Content-Type': 'application/vnd.api+json'} 988 | ) 989 | body = resp.json() 990 | if 'errors' in body: 991 | raise ValueError(body['errors']) 992 | return body 993 | 994 | def set_hvac_status(self, action: HVACAction, target_temperature: int=21, start: datetime.datetime=None, srp: str=None): 995 | if Feature.CLIMATE_ON_OFF not in self.features: 996 | return 997 | 998 | if target_temperature < 16 or target_temperature > 26: 999 | raise ValueError('Temperature must be between 16 & 26 degrees') 1000 | 1001 | attributes = { 1002 | 'action': action.value 1003 | } 1004 | if action == HVACAction.START: 1005 | attributes['targetTemperature'] = target_temperature 1006 | if start is not None: 1007 | attributes['startDateTime'] = start.isoformat(timespec='seconds') 1008 | if srp is not None: 1009 | attributes['srp'] = srp 1010 | 1011 | resp = self.session.oauth.post( 1012 | '{}v1/cars/{}/actions/hvac-start'.format(self.session.settings['car_adapter_base_url'], self.vin), 1013 | data=json.dumps({ 1014 | 'data': { 1015 | 'type': 'HvacStart', 1016 | 'attributes': attributes 1017 | } 1018 | }), 1019 | headers={'Content-Type': 'application/vnd.api+json'} 1020 | ) 1021 | body = resp.json() 1022 | if 'errors' in body: 1023 | raise ValueError(body['errors']) 1024 | return body 1025 | 1026 | def lock_unlock(self, srp: str, action: str, group: LockableDoorGroup=None): 1027 | if Feature.APP_DOOR_LOCKING not in self.features: 1028 | return 1029 | assert action in ('lock', 'unlock') 1030 | if group is None: 1031 | group = LockableDoorGroup.DOORS_AND_HATCH 1032 | resp = self.session.oauth.post( 1033 | '{}v1/cars/{}/actions/lock-unlock"'.format(self.session.settings['car_adapter_base_url'], self.vin), 1034 | data=json.dumps({ 1035 | 'data': { 1036 | 'type': 'LockUnlock', 1037 | 'attributes': { 1038 | 'lock': action, 1039 | 'doorType': group.value, 1040 | 'srp': srp 1041 | } 1042 | } 1043 | }), 1044 | headers={'Content-Type': 'application/vnd.api+json'} 1045 | ) 1046 | body = resp.json() 1047 | if 'errors' in body: 1048 | raise ValueError(body['errors']) 1049 | return body 1050 | 1051 | def lock(self, srp: str, group: LockableDoorGroup=None): 1052 | return self.lock_unlock(srp, 'lock', group) 1053 | 1054 | def unlock(self, srp: str, group: LockableDoorGroup=None): 1055 | return self.lock_unlock(srp, 'unlock', group) 1056 | 1057 | def fetch_hvac_status(self): 1058 | resp = self.session.oauth.get( 1059 | '{}v1/cars/{}/hvac-status'.format(self.session.settings['car_adapter_base_url'], self.vin), 1060 | headers={'Content-Type': 'application/vnd.api+json'} 1061 | ) 1062 | body = resp.json() 1063 | if 'errors' in body: 1064 | raise ValueError(body['errors']) 1065 | hvac_data = body['data']['attributes'] 1066 | self.external_temperature = hvac_data.get('externalTemperature') 1067 | self.internal_temperature = hvac_data.get('internalTemperature') 1068 | if 'hvacStatus' in hvac_data: 1069 | self.hvac_status = HVACStatus(hvac_data['hvacStatus']) 1070 | if 'nextHvacStartDate' in hvac_data: 1071 | self.next_hvac_start_date = datetime.datetime.fromisoformat(hvac_data['nextHvacStartDate'].replace('Z','+00:00')) 1072 | self.next_target_temperature = hvac_data.get('nextTargetTemperature') 1073 | self.hvac_status_last_updated = datetime.datetime.fromisoformat(hvac_data['lastUpdateTime'].replace('Z','+00:00')) 1074 | 1075 | def refresh_battery_status(self): 1076 | resp = self.session.oauth.post( 1077 | '{}v1/cars/{}/actions/refresh-battery-status'.format(self.session.settings['car_adapter_base_url'], self.vin), 1078 | data=json.dumps({ 1079 | 'data': {'type': 'RefreshBatteryStatus'} 1080 | }), 1081 | headers={'Content-Type': 'application/vnd.api+json'} 1082 | ) 1083 | body = resp.json() 1084 | if 'errors' in body: 1085 | raise ValueError(body['errors']) 1086 | return body 1087 | 1088 | def fetch_battery_status(self): 1089 | if Feature.BATTERY_STATUS not in self.features: 1090 | return 1091 | resp = self.session.oauth.get( 1092 | '{}v1/cars/{}/battery-status'.format(self.session.settings['car_adapter_base_url'], self.vin), 1093 | headers={'Content-Type': 'application/vnd.api+json'} 1094 | ) 1095 | body = resp.json() 1096 | if 'errors' in body: 1097 | raise ValueError(body['errors']) 1098 | battery_data = body['data']['attributes'] 1099 | self.battery_capacity = battery_data['batteryCapacity'] # kWh 1100 | self.battery_level = battery_data['batteryLevel'] # % 1101 | self.battery_temperature = battery_data.get('batteryTemperature') # Fahrenheit? 1102 | # same meaning as battery level, different scale. 240 = 100% 1103 | self.battery_bar_level = battery_data['batteryBarLevel'] 1104 | self.instantaneous_power = battery_data.get('instantaneousPower') # kW 1105 | self.charging_speed = ChargingSpeed(battery_data.get('chargePower')) 1106 | self.charge_time_required_to_full = { 1107 | ChargingSpeed.FAST: battery_data['timeRequiredToFullFast'], 1108 | ChargingSpeed.NORMAL: battery_data['timeRequiredToFullNormal'], 1109 | ChargingSpeed.SLOW: battery_data['timeRequiredToFullSlow'], 1110 | } 1111 | self.range_hvac_off = battery_data['rangeHvacOff'] 1112 | self.range_hvac_on = battery_data['rangeHvacOn'] 1113 | self.charging = ChargingStatus(battery_data['chargeStatus']) 1114 | self.plugged_in = PluggedStatus(battery_data['plugStatus']) 1115 | if 'vehiclePlugTimestamp' in battery_data: 1116 | self.plugged_in_time = datetime.datetime.fromisoformat(battery_data['vehiclePlugTimestamp'].replace('Z','+00:00')) 1117 | if 'vehicleUnplugTimestamp' in battery_data: 1118 | self.unplugged_time = datetime.datetime.fromisoformat(battery_data['vehicleUnplugTimestamp'].replace('Z','+00:00')) 1119 | self.battery_status_last_updated = datetime.datetime.fromisoformat(battery_data['lastUpdateTime'].replace('Z','+00:00')) 1120 | 1121 | def fetch_energy_unit_cost(self): 1122 | resp = self.session.oauth.get( 1123 | '{}v1/cars/{}/energy-unit-cost'.format(self.session.settings['car_adapter_base_url'], self.vin) 1124 | ) 1125 | body = resp.json() 1126 | if 'errors' in body: 1127 | raise ValueError(body['errors']) 1128 | energy_cost_data = body['data']['attributes'] 1129 | self.electricity_unit_cost = energy_cost_data.get('electricityUnitCost') 1130 | self.combustion_fuel_unit_cost = energy_cost_data.get('fuelUnitCost') 1131 | return body 1132 | 1133 | def set_energy_unit_cost(self, cost): 1134 | resp = self.session.oauth.post( 1135 | '{}v1/cars/{}/energy-unit-cost'.format(self.session.settings['car_adapter_base_url'], self.vin), 1136 | data=json.dumps({ 1137 | 'data': { 1138 | 'type': {} 1139 | } 1140 | }) 1141 | ) 1142 | body = resp.json() 1143 | if 'errors' in body: 1144 | raise ValueError(body['errors']) 1145 | 1146 | def fetch_trip_histories(self, period: Period=None, start: datetime.date=None, end: datetime.date=None): 1147 | if period is None: 1148 | period = Period.DAILY 1149 | if start is None: 1150 | start = datetime.date.today() 1151 | if end is None: 1152 | end = start 1153 | resp = self.session.oauth.get( 1154 | '{}v1/cars/{}/trip-history'.format(self.session.settings['car_adapter_base_url'], self.vin), 1155 | params={ 1156 | 'type': period.value, 1157 | 'start': start.isoformat(), 1158 | 'end': end.isoformat() 1159 | } 1160 | ) 1161 | body = resp.json() 1162 | if 'errors' in body: 1163 | raise ValueError(body['errors']) 1164 | return [TripSummary(s, self.vin) for s in body['data']['attributes']['summaries']] 1165 | 1166 | def fetch_notifications( 1167 | self, 1168 | language: Language=None, 1169 | category_key: NotificationCategoryKey=None, 1170 | status: NotificationStatus=None, 1171 | start: datetime.datetime=None, 1172 | end: datetime.datetime=None, 1173 | # offset 1174 | from_: int=1, 1175 | # limit 1176 | to: int=20, 1177 | order: Order=None 1178 | ): 1179 | 1180 | if language is None: 1181 | language = Language.EN 1182 | params = { 1183 | 'realm': self.session.copy_realm, 1184 | 'langCode': language.value, 1185 | } 1186 | if category_key is not None: 1187 | params['categoryKey'] = category_key.value 1188 | if status is not None: 1189 | params['status'] = status.value 1190 | if start is not None: 1191 | params['start'] = start.isoformat(timespec='seconds') 1192 | if start.tzinfo is None: 1193 | # Assume UTC 1194 | params['start'] += 'Z' 1195 | if end is not None: 1196 | params['end'] = start.isoformat(timespec='seconds') 1197 | if end.tzinfo is None: 1198 | # Assume UTC 1199 | params['end'] += 'Z' 1200 | resp = self.session.oauth.get( 1201 | '{}v2/notifications/users/{}/vehicles/{}'.format(self.session.settings['notifications_base_url'], self.user_id, self.vin), 1202 | params=params 1203 | ) 1204 | body = resp.json() 1205 | if 'errors' in body: 1206 | raise ValueError(body['errors']) 1207 | return [Notification(m, language, self.vin) for m in body['data']['attributes']['messages']] 1208 | 1209 | def mark_notifications(self, messages: List[Notification]): 1210 | """Take a list of notifications and set their status remotely 1211 | to the one held locally (read / unread).""" 1212 | 1213 | resp = self.session.oauth.post( 1214 | '{}v2/notifications/users/{}/vehicles/{}'.format(self.session.settings['notifications_base_url'], self.user_id, self.vin), 1215 | data=json.dumps([ 1216 | {'notificationId': m.id, 'status': m.status.value} 1217 | for m in messages 1218 | ]) 1219 | ) 1220 | body = resp.json() 1221 | if 'errors' in body: 1222 | raise ValueError(body['errors']) 1223 | return body 1224 | 1225 | def fetch_notification_settings(self, language: Language=None): 1226 | if language is None: 1227 | language = Language.EN 1228 | params = { 1229 | 'langCode': language.value, 1230 | } 1231 | resp = self.session.oauth.get( 1232 | '{}v1/rules/settings/users/{}/vehicles/{}'.format(self.session.settings['notifications_base_url'], self.user_id, self.vin), 1233 | params=params 1234 | ) 1235 | body = resp.json() 1236 | if 'errors' in body: 1237 | raise ValueError(body['errors']) 1238 | return [ 1239 | NotificationRule(r, language, self.vin) 1240 | for r in body['settings'] 1241 | ] 1242 | 1243 | def update_notification_settings(self): 1244 | # TODO 1245 | pass 1246 | 1247 | def fetch_cockpit(self): 1248 | resp = self.session.oauth.get( 1249 | "{}v1/cars/{}/cockpit".format(self.session.settings['car_adapter_base_url'], self.vin) 1250 | ) 1251 | body = resp.json() 1252 | if 'errors' in body: 1253 | raise ValueError(body['errors']) 1254 | 1255 | cockpit_data = body['data']['attributes'] 1256 | self.eco_score = cockpit_data.get('ecoScore') 1257 | self.fuel_autonomy = cockpit_data.get('fuelAutonomy') 1258 | self.fuel_consumption = cockpit_data.get('fuelConsumption') 1259 | self.fuel_economy = cockpit_data.get('fuelEconomy') 1260 | self.fuel_level = cockpit_data.get('fuelLevel') 1261 | if 'fuelLowWarning' in cockpit_data: 1262 | self.fuel_low_warning = bool(cockpit_data['fuelLowWarning']) 1263 | self.fuel_quantity = cockpit_data.get('fuelQuantity') # litres 1264 | self.mileage = cockpit_data.get('mileage') 1265 | self.total_mileage = cockpit_data['totalMileage'] 1266 | 1267 | 1268 | class TripSummary: 1269 | 1270 | def __init__(self, data, vin): 1271 | self.vin = vin 1272 | self.trip_count = data['tripsNumber'] 1273 | self.total_distance = data['distance'] # km 1274 | self.total_duration = data['duration'] # minutes 1275 | self.first_trip_start = datetime.datetime.fromisoformat(data['firstTripStart'].replace('Z','+00:00')) 1276 | self.last_trip_end = datetime.datetime.fromisoformat(data['lastTripEnd'].replace('Z','+00:00')) 1277 | self.consumed_fuel = data['consumedFuel'] # litres 1278 | self.consumed_electricity = data['consumedElectricity'] # W 1279 | self.saved_electricity = data['savedElectricity'] # W 1280 | if 'day' in data: 1281 | self.start = self.end = datetime.date(int(data['day'][:4]), int(data['day'][4:6]), int(data['day'][6:])) 1282 | elif 'month' in data: 1283 | start_year = int(data['month'][:4]) 1284 | start_month = int(data['month'][4:]) 1285 | end_month = start_month + 1 1286 | end_year = start_year 1287 | if end_month > 12: 1288 | end_month = 1 1289 | end_year = end_year + 1 1290 | self.start = datetime.date(start_year, start_month, 1) 1291 | self.end = datetime.date(end_year, end_month) - datetime.timedelta(days=1) 1292 | elif 'year' in data: 1293 | self.start = datetime.date(int(data['year']), 1, 1) 1294 | self.end = datetime.date(int(data['year']) + 1, 1, 1) - datetime.timedelta(days=1) 1295 | 1296 | def __str__(self): 1297 | return '{} trips covering {} kilometres over {} minutes using {} litres fuel and {} kilowatt-hours electricity'.format( 1298 | self.trip_count, self.total_distance, self.total_duration, self.consumed_fuel, self.consumed_electricity 1299 | ) 1300 | 1301 | 1302 | class NotificationRule: 1303 | 1304 | def __init__(self, data, language, vin): 1305 | self.vin = vin 1306 | self.language = language 1307 | self.key = NotificationRuleKey(data['ruleKey']) 1308 | self.title = data['ruleTitle'] 1309 | self.description = data['ruleDescription'] 1310 | self.priority = NotificationPriority(data['priority']) 1311 | self.status = NotificationRuleStatus(data['status']) 1312 | self.channels = [ 1313 | NotificationChannelType(c['channelType']) 1314 | for c in data['channels'] 1315 | ] 1316 | self.category = NotificationCategory(NotificationCategoryKey(data['categoryKey']), data['categoryTitle']) 1317 | self.notification_type = None 1318 | if 'notificationKey' in data: 1319 | self.notification_type = NotificationType( 1320 | NotificationTypeKey(data['notificationKey']), 1321 | data['notificationTitle'], 1322 | data['notificationMessage'], 1323 | self.category, 1324 | ) 1325 | 1326 | def __str__(self): 1327 | return '{}: {} ({})'.format( 1328 | self.title or self.key, 1329 | self.status.value, 1330 | ', '.join(c.value for c in self.channels) 1331 | ) 1332 | 1333 | 1334 | class SRP: 1335 | 1336 | @classmethod 1337 | def enroll(cls, user_id, vin): 1338 | salt, verifier = '0'*20, 'ABCDEFGH'*64 1339 | # salt = 20 hex chars, verifier = 512 hex chars 1340 | return (salt, verifier) 1341 | 1342 | @classmethod 1343 | def generate_a(cls): 1344 | # 512 hex chars 1345 | return '' 1346 | 1347 | @classmethod 1348 | def generate_proof(cls, salt, b, user_id, confirm_code, order): 1349 | """Required for remote lock / unlock.""" 1350 | # order = '/' 1351 | # where PERMISSIONS is one of: 1352 | # * "BCI/Block" 1353 | # * "BCI/Unblock" 1354 | # * "RC/Delayed" 1355 | # * "RC/Start" 1356 | # * "RC/Stop" 1357 | # * "RES/DoubleStart" 1358 | # * "RES/Start" 1359 | # * "RES/Stop" 1360 | # * "RHL/Start/HornOnly" 1361 | # * "RHL/Start/HornLight" 1362 | # * "RHL/Start/LightOnly" 1363 | # * "RHL/Stop" 1364 | # * "RLU/Lock" 1365 | # * "RLU/Unlock" 1366 | # * "RPC_ICE/Start" 1367 | # * "RPC_ICE/Stop" 1368 | # * "RPU_CCS/Disable" 1369 | # * "RPU_CCS/Enable" 1370 | # * "RPU_SVTB/Disable" 1371 | # * "RPU_SVTB/Enable" 1372 | pass 1373 | 1374 | 1375 | 1376 | if __name__ == '__main__': 1377 | import pprint 1378 | import sys 1379 | 1380 | region = sys.argv[1] 1381 | username = sys.argv[2] 1382 | password = sys.argv[3] 1383 | if len(sys.argv) > 4: 1384 | srp = sys.argv[4] 1385 | 1386 | nci = NCISession(region) 1387 | nci.login(username, password) 1388 | user_id = nci.get_user_id() 1389 | vehicles = nci.fetch_vehicles() 1390 | for vehicle in vehicles: 1391 | vehicle.fetch_cockpit() 1392 | vehicle.fetch_all() 1393 | pprint.pprint(vars(vehicle)) 1394 | print('today trip summary') 1395 | trip_summaries = vehicle.fetch_trip_histories() 1396 | print('\n'.join(map(str, trip_summaries))) 1397 | print('last notifications') 1398 | notifications = vehicle.fetch_notifications() 1399 | print('\n'.join(map(str, notifications))) 1400 | notifications[0].fetch_details() 1401 | 1402 | -------------------------------------------------------------------------------- /kamereon/lock.py: -------------------------------------------------------------------------------- 1 | """Support for Kamereon car locks.""" 2 | import logging 3 | 4 | from homeassistant.components.lock import LockDevice 5 | from homeassistant.const import STATE_UNKNOWN 6 | 7 | from . import KamereonEntity 8 | from .kamereon import Door, LockStatus 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | async def async_setup_platform(hass, config, async_add_entities, vehicle=None): 14 | """Set up the Kamereon lock.""" 15 | if vehicle is None: 16 | return 17 | 18 | async_add_entities([KamereonLock(vehicle)]) 19 | 20 | 21 | class KamereonLock(KamereonEntity, LockDevice): 22 | """Represents a car lock.""" 23 | 24 | @property 25 | def _entity_name(self): 26 | return 'lock' 27 | 28 | @property 29 | def is_locked(self): 30 | """Return true if lock is locked.""" 31 | if self.vehicle.lock_status is None: 32 | return STATE_UNKNOWN 33 | return self.vehicle.lock_status is LockStatus.LOCKED 34 | 35 | async def async_lock(self, **kwargs): 36 | """Lock the car.""" 37 | self.vehicle.lock() 38 | 39 | async def async_unlock(self, **kwargs): 40 | """Unlock the car.""" 41 | self.vehicle.unlock() 42 | 43 | @property 44 | def device_state_attributes(self): 45 | a = KamereonEntity.device_state_attributes.fget(self) 46 | lock_status = self.vehicle.lock_status 47 | if lock_status is None: 48 | lock_status = STATE_UNKNOWN 49 | else: 50 | lock_status = lock_status.value 51 | a.update({ 52 | 'last_updated': self.vehicle.lock_status_last_updated, 53 | 'lock_status': lock_status, 54 | }) -------------------------------------------------------------------------------- /kamereon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "kamereon", 3 | "name": "Kamereon", 4 | "documentation": "https://github.com/mitchellrj/kamereon-python", 5 | "requirements": ["requests", "requests_oauthlib", "pytz"], 6 | "dependencies": [], 7 | "codeowners": [] 8 | } -------------------------------------------------------------------------------- /kamereon/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Kamereon car sensors.""" 2 | import logging 3 | 4 | from homeassistant.const import ( 5 | DEVICE_CLASS_BATTERY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, 6 | DEVICE_CLASS_TIMESTAMP, LENGTH_KILOMETERS, POWER_WATT, STATE_UNKNOWN, 7 | TEMP_CELSIUS, TIME_MINUTES, UNIT_PERCENTAGE, VOLUME_LITERS) 8 | 9 | from . import DATA_KEY, KamereonEntity 10 | from .kamereon import ChargingSpeed 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | async def async_setup_platform(hass, config, async_add_entities, vehicle=None): 16 | """Set up the Kamereon sensors.""" 17 | if vehicle is None: 18 | return 19 | async_add_entities([ 20 | BatteryLevelSensor(vehicle), 21 | BatteryTemperatureSensor(vehicle), 22 | ChargingPowerSensor(vehicle), 23 | ChargingSpeedSensor(vehicle), 24 | ChargeTimeRequiredSensor(vehicle, ChargingSpeed.FAST), 25 | ChargeTimeRequiredSensor(vehicle, ChargingSpeed.NORMAL), 26 | ChargeTimeRequiredSensor(vehicle, ChargingSpeed.SLOW), 27 | ExternalTemperatureSensor(vehicle), 28 | RangeSensor(vehicle, hvac=True), 29 | RangeSensor(vehicle, hvac=False), 30 | TimestampSensor(vehicle, 'plugged_in_time', 'plugged in time'), 31 | TimestampSensor(vehicle, 'unplugged_time', 'unplugged time'), 32 | TimestampSensor(vehicle, 'battery_status_last_updated', 'battery status last updated time'), 33 | TimestampSensor(vehicle, 'location_last_updated', 'location last updated time'), 34 | TimestampSensor(vehicle, 'lock_status_last_updated', 'lock status last updated time'), 35 | FuelLevelSensor(vehicle), 36 | FuelQuantitySensor(vehicle), 37 | MileageSensor(vehicle, total=False), 38 | MileageSensor(vehicle, total=True), 39 | ]) 40 | 41 | 42 | class BatteryLevelSensor(KamereonEntity): 43 | """Representation of a Kamereon car sensor.""" 44 | 45 | @property 46 | def state(self): 47 | """Return the state.""" 48 | return self.vehicle.battery_bar_level 49 | 50 | @property 51 | def _entity_name(self): 52 | return 'battery level' 53 | 54 | @property 55 | def unit_of_measurement(self): 56 | """Return the unit of measurement.""" 57 | return UNIT_PERCENTAGE 58 | 59 | @property 60 | def device_class(self): 61 | """Return the class of this sensor.""" 62 | return DEVICE_CLASS_BATTERY 63 | 64 | @property 65 | def device_state_attributes(self): 66 | """Return device specific state attributes.""" 67 | a = KamereonEntity.device_state_attributes.fget(self) 68 | a.update({ 69 | 'battery_capacity': self.vehicle.battery_capacity, 70 | 'battery_level': self.vehicle.battery_level, 71 | }) 72 | 73 | 74 | class FuelLevelSensor(KamereonEntity): 75 | """Representation of a Kamereon car sensor.""" 76 | 77 | @property 78 | def state(self): 79 | """Return the state.""" 80 | if self.vehicle.fuel_level is None: 81 | return STATE_UNKNOWN 82 | return self.vehicle.fuel_level 83 | 84 | @property 85 | def _entity_name(self): 86 | return 'fuel level' 87 | 88 | @property 89 | def unit_of_measurement(self): 90 | """Return the unit of measurement.""" 91 | return UNIT_PERCENTAGE 92 | 93 | 94 | class FuelQuantitySensor(KamereonEntity): 95 | """Representation of a Kamereon car sensor.""" 96 | 97 | @property 98 | def state(self): 99 | """Return the state.""" 100 | if self.vehicle.fuel_quantity is None: 101 | return STATE_UNKNOWN 102 | return self.vehicle.fuel_quantity 103 | 104 | @property 105 | def _entity_name(self): 106 | return 'fuel quantity' 107 | 108 | @property 109 | def unit_of_measurement(self): 110 | """Return the unit of measurement.""" 111 | return VOLUME_LITERS 112 | 113 | 114 | class BatteryTemperatureSensor(KamereonEntity): 115 | """Representation of a Kamereon car sensor.""" 116 | 117 | @property 118 | def state(self): 119 | """Return the state.""" 120 | if self.vehicle.battery_temperature is None: 121 | return STATE_UNKNOWN 122 | return self.vehicle.battery_temperature 123 | 124 | @property 125 | def _entity_name(self): 126 | return 'battery temperature' 127 | 128 | @property 129 | def unit_of_measurement(self): 130 | """Return the unit of measurement.""" 131 | return TEMP_CELSIUS 132 | 133 | @property 134 | def device_class(self): 135 | """Return the class of this sensor.""" 136 | return DEVICE_CLASS_TEMPERATURE 137 | 138 | @property 139 | def device_state_attributes(self): 140 | """Return device specific state attributes.""" 141 | a = KamereonEntity.device_state_attributes.fget(self) 142 | a.update({ 143 | 'battery_capacity': self.vehicle.battery_capacity, 144 | 'battery_bar_level': self.vehicle.battery_bar_level, 145 | }) 146 | return a 147 | 148 | 149 | class ExternalTemperatureSensor(KamereonEntity): 150 | """Representation of a Kamereon car sensor.""" 151 | 152 | @property 153 | def state(self): 154 | """Return the state.""" 155 | if self.vehicle.external_temperature is None: 156 | return STATE_UNKNOWN 157 | return self.vehicle.external_temperature 158 | 159 | @property 160 | def _entity_name(self): 161 | return 'external temperature' 162 | 163 | @property 164 | def unit_of_measurement(self): 165 | """Return the unit of measurement.""" 166 | return TEMP_CELSIUS 167 | 168 | @property 169 | def device_class(self): 170 | """Return the class of this sensor.""" 171 | return DEVICE_CLASS_TEMPERATURE 172 | 173 | 174 | class ChargingPowerSensor(KamereonEntity): 175 | """Representation of a Kamereon car sensor.""" 176 | 177 | @property 178 | def state(self): 179 | """Return the state.""" 180 | if self.vehicle.instantaneous_power is None: 181 | return STATE_UNKNOWN 182 | return self.vehicle.instantaneous_power * 1000 183 | 184 | @property 185 | def _entity_name(self): 186 | return 'charging power' 187 | 188 | @property 189 | def unit_of_measurement(self): 190 | """Return the unit of measurement.""" 191 | return POWER_WATT 192 | 193 | @property 194 | def device_class(self): 195 | """Return the class of this sensor.""" 196 | return DEVICE_CLASS_POWER 197 | 198 | @property 199 | def device_state_attributes(self): 200 | """Return device specific state attributes.""" 201 | a = KamereonEntity.device_state_attributes.fget(self) 202 | a.update({ 203 | 'battery_capacity': self.vehicle.battery_capacity, 204 | 'battery_bar_level': self.vehicle.battery_bar_level, 205 | }) 206 | 207 | 208 | class ChargingSpeedSensor(KamereonEntity): 209 | """Representation of a Kamereon car sensor.""" 210 | 211 | @property 212 | def _entity_name(self): 213 | return 'charging speed' 214 | 215 | @property 216 | def state(self): 217 | """Return the state.""" 218 | if self.vehicle.charging_speed is None: 219 | return STATE_UNKNOWN 220 | return self.vehicle.charging_speed.value 221 | 222 | 223 | class ChargeTimeRequiredSensor(KamereonEntity): 224 | """Representation of a Kamereon car sensor.""" 225 | 226 | CHARGING_SPEED_NAME = { 227 | ChargingSpeed.FAST: 'fast', 228 | ChargingSpeed.NORMAL: 'normal', 229 | ChargingSpeed.SLOW: 'slow', 230 | } 231 | 232 | def __init__(self, vehicle, charging_speed): 233 | KamereonEntity.__init__(self, vehicle) 234 | self.charging_speed = charging_speed 235 | 236 | @property 237 | def unit_of_measurement(self): 238 | """Return the unit of measurement.""" 239 | return TIME_MINUTES 240 | 241 | @property 242 | def _entity_name(self): 243 | return 'charging time required to full ({})'.format(self.CHARGING_SPEED_NAME[self.charging_speed]) 244 | 245 | @property 246 | def state(self): 247 | """Return the state.""" 248 | if self.vehicle.charge_time_required_to_full[self.charging_speed] is None: 249 | return STATE_UNKNOWN 250 | return self.vehicle.charge_time_required_to_full[self.charging_speed] 251 | 252 | 253 | class RangeSensor(KamereonEntity): 254 | """Representation of a Kamereon car sensor.""" 255 | 256 | def __init__(self, vehicle, hvac): 257 | KamereonEntity.__init__(self, vehicle) 258 | self.hvac = hvac 259 | 260 | @property 261 | def _entity_name(self): 262 | return 'range (HVAC {})'.format('on' if self.hvac else 'off') 263 | 264 | @property 265 | def unit_of_measurement(self): 266 | """Return the unit of measurement.""" 267 | return LENGTH_KILOMETERS 268 | 269 | @property 270 | def state(self): 271 | """Return the state.""" 272 | val = getattr(self.vehicle, 'range_hvac_{}'.format('on' if self.hvac else 'off')) 273 | if val is None: 274 | return STATE_UNKNOWN 275 | return val 276 | 277 | 278 | class MileageSensor(KamereonEntity): 279 | """Representation of a Kamereon car sensor.""" 280 | 281 | def __init__(self, vehicle, total=False): 282 | KamereonEntity.__init__(self, vehicle) 283 | self.total = total 284 | 285 | @property 286 | def _entity_name(self): 287 | return '{}mileage'.format('total ' if self.total else '') 288 | 289 | @property 290 | def unit_of_measurement(self): 291 | """Return the unit of measurement.""" 292 | return LENGTH_KILOMETERS 293 | 294 | @property 295 | def state(self): 296 | """Return the state.""" 297 | val = getattr(self.vehicle, '{}mileage'.format('total_' if self.total else '')) 298 | if val is None: 299 | return STATE_UNKNOWN 300 | return val 301 | 302 | 303 | class TimestampSensor(KamereonEntity): 304 | """Representation of a Kamereon car sensor.""" 305 | 306 | def __init__(self, vehicle, attribute, name): 307 | KamereonEntity.__init__(self, vehicle) 308 | self.attribute = attribute 309 | self.__entity_name = name 310 | 311 | @property 312 | def _entity_name(self): 313 | return self.__entity_name 314 | 315 | @property 316 | def device_class(self): 317 | """Return the device class.""" 318 | return DEVICE_CLASS_TIMESTAMP 319 | 320 | @property 321 | def state(self): 322 | """Return the state.""" 323 | val = getattr(self.vehicle, self.attribute) 324 | if val is None: 325 | return STATE_UNKNOWN 326 | return val.isoformat() -------------------------------------------------------------------------------- /kamereon/switch.py: -------------------------------------------------------------------------------- 1 | """Support for Kamereon switches.""" 2 | import logging 3 | 4 | from homeassistant.helpers.entity import ToggleEntity 5 | 6 | from . import DATA_KEY, KamereonEntity 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 12 | """Set up a Kamereon car switch.""" 13 | if discovery_info is None: 14 | return 15 | #async_add_entities([KamereonSwitch(discovery_info)]) 16 | 17 | 18 | class KamereonSwitch(KamereonEntity, ToggleEntity): 19 | """Representation of a Kamereon car switch.""" 20 | 21 | @property 22 | def _switch(self): 23 | return NotImplemented 24 | 25 | @property 26 | def is_on(self): 27 | """Return true if switch is on.""" 28 | return self._state 29 | 30 | async def async_turn_on(self, **kwargs): 31 | """Turn the switch on.""" 32 | await self.instrument.turn_on() 33 | 34 | async def async_turn_off(self, **kwargs): 35 | """Turn the switch off.""" 36 | await self.instrument.turn_off() -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytz 2 | requests 3 | requests_oauthlib --------------------------------------------------------------------------------