├── .gitignore ├── .tool-versions ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── enquiry.md │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── main.yml ├── requirements.txt ├── tests ├── __init__.py ├── conftest.py ├── test_device_tracker.py ├── test_binary_sensor.py ├── test_button.py ├── test_climate.py ├── test_config_flow.py └── test_sensor.py ├── custom_components └── nissan_connect │ ├── kamereon │ ├── __init__.py │ ├── kamereon_const.py │ └── kamereon.py │ ├── manifest.json │ ├── const.py │ ├── device_tracker.py │ ├── base.py │ ├── button.py │ ├── binary_sensor.py │ ├── __init__.py │ ├── translations │ ├── pt.json │ ├── en.json │ ├── no.json │ ├── dk.json │ ├── pl.json │ ├── de.json │ ├── nl.json │ ├── es.json │ ├── ru.json │ ├── it.json │ └── fr.json │ ├── climate.py │ ├── config_flow.py │ ├── coordinator.py │ └── sensor.py ├── pyproject.toml ├── hacs.json ├── requirements.test.txt ├── LICENSE.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | python 3.11.3 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: dan-r 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | requests_oauthlib -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the NissanConnect integration.""" -------------------------------------------------------------------------------- /custom_components/nissan_connect/kamereon/__init__.py: -------------------------------------------------------------------------------- 1 | from .kamereon import * 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | asyncio_mode = "auto" 3 | filterwarnings = [ 4 | "ignore::RuntimeWarning" 5 | ] -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NissanConnect [EU]", 3 | "render_readme": true, 4 | "homeassistant": "2023.11.0" 5 | } 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enquiry.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enquiry 3 | about: Ask a question 4 | title: '' 5 | labels: enquiry 6 | assignees: dan-r 7 | 8 | --- 9 | -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | coverage==7.4.3 2 | pytest==8.0.2 3 | pytest-asyncio==0.23.5 4 | pytest-cov==4.1.0 5 | pytest-homeassistant-custom-component==0.13.109 6 | requests 7 | requests_oauthlib 8 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Global fixtures for custom integration.""" 2 | import pytest 3 | import pytest_socket 4 | 5 | @pytest.fixture(autouse=True) 6 | def auto_enable_custom_integrations(enable_custom_integrations): 7 | """Enable custom integrations defined in the test dir.""" 8 | yield 9 | 10 | def enable_external_sockets(): 11 | pytest_socket.enable_socket() 12 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "nissan_connect", 3 | "name": "NissanConnect", 4 | "codeowners": [ 5 | "@dan-r" 6 | ], 7 | "config_flow": true, 8 | "documentation": "https://github.com/dan-r/HomeAssistant-NissanConnect/", 9 | "iot_class": "cloud_polling", 10 | "issue_tracker": "https://github.com/dan-r/HomeAssistant-NissanConnect/issues", 11 | "requirements": ["requests", "requests_oauthlib"], 12 | "version": "1.0.0" 13 | } 14 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "nissan_connect" 2 | CONFIG_VERSION = 1 3 | ENTITY_TYPES = ["binary_sensor", "sensor", "button", "climate", "device_tracker"] 4 | 5 | DATA_VEHICLES = "vehicles" 6 | DATA_COORDINATOR_FETCH = "coordinator_fetch" 7 | DATA_COORDINATOR_POLL = "coordinator_poll" 8 | DATA_COORDINATOR_STATISTICS = "coordinator_statistics" 9 | 10 | DEFAULT_INTERVAL_POLL = 0 11 | DEFAULT_INTERVAL_CHARGING = 15 12 | DEFAULT_INTERVAL_STATISTICS = 60 13 | 14 | DEFAULT_INTERVAL_FETCH = 10 15 | 16 | DEFAULT_REGION = "EU" 17 | REGIONS = ["EU"] 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: dan-r 7 | 8 | --- 9 | 10 | **What happened?** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Log Output** 20 | Any relevant logs from Home Assistant. 21 | 22 | **Home Assistant Version** 23 | Find this in Settings > About. 24 | 25 | **Integration Version** 26 | Find this from HACS. 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: dan-r 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | on: 3 | schedule: 4 | - cron: '0 1 * * *' 5 | push: 6 | branches: 7 | - dev 8 | - main 9 | pull_request: 10 | jobs: 11 | validate: 12 | name: Validate 13 | runs-on: "ubuntu-latest" 14 | steps: 15 | - uses: "actions/checkout@v4" 16 | - uses: "home-assistant/actions/hassfest@master" 17 | - name: HACS Action 18 | uses: "hacs/action@main" 19 | with: 20 | category: "integration" 21 | pytest: 22 | name: Unit Tests 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | - name: asdf_install 30 | uses: asdf-vm/actions/install@v3 31 | - name: Install Python modules 32 | run: | 33 | pip install -r requirements.test.txt 34 | - name: Run unit tests 35 | run: | 36 | python -m pytest tests -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Daniel Raper 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_device_tracker.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import AsyncMock, MagicMock 3 | from homeassistant.components.device_tracker.const import SourceType 4 | 5 | from custom_components.nissan_connect.device_tracker import ( 6 | async_setup_entry, 7 | KamereonDeviceTracker, 8 | ) 9 | 10 | @pytest.fixture 11 | def mock_hass(): 12 | hass = MagicMock() 13 | hass.data = { 14 | 'nissan_connect': { 15 | 'test@example.com': { 16 | 'vehicles': { 17 | 'vehicle_1': MagicMock(features=['MY_CAR_FINDER'], location=(12.34, 56.78)), 18 | }, 19 | 'coordinator_fetch': MagicMock(), 20 | } 21 | } 22 | } 23 | return hass 24 | 25 | @pytest.fixture 26 | def mock_entry(): 27 | return MagicMock(data={'email': 'test@example.com'}) 28 | 29 | @pytest.fixture 30 | def mock_vehicle(): 31 | return MagicMock(location=(12.34, 56.78)) 32 | 33 | @pytest.fixture 34 | def mock_coordinator(): 35 | return MagicMock() 36 | 37 | def test_kamereon_device_tracker_properties(mock_vehicle, mock_coordinator): 38 | tracker = KamereonDeviceTracker(mock_coordinator, mock_vehicle) 39 | 40 | assert tracker.latitude == 12.34 41 | assert tracker.longitude == 56.78 42 | assert tracker.source_type == SourceType.GPS 43 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/device_tracker.py: -------------------------------------------------------------------------------- 1 | """Support for tracking a Kamereon car.""" 2 | import logging 3 | 4 | from homeassistant.components.device_tracker import TrackerEntity 5 | from homeassistant.components.device_tracker.const import SourceType 6 | from .base import KamereonEntity 7 | from .kamereon import Feature 8 | from .const import DOMAIN, DATA_COORDINATOR_FETCH, DATA_VEHICLES 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | async def async_setup_entry(hass, entry, async_add_entities): 13 | account_id = entry.data['email'] 14 | 15 | data = hass.data[DOMAIN][account_id][DATA_VEHICLES] 16 | coordinator = hass.data[DOMAIN][account_id][DATA_COORDINATOR_FETCH] 17 | 18 | entities = [] 19 | 20 | for vehicle in data: 21 | if Feature.MY_CAR_FINDER in data[vehicle].features: 22 | entities.append(KamereonDeviceTracker(coordinator, data[vehicle])) 23 | 24 | async_add_entities(entities, update_before_add=True) 25 | 26 | 27 | class KamereonDeviceTracker(KamereonEntity, TrackerEntity): 28 | _attr_translation_key = "location" 29 | 30 | @property 31 | def latitude(self) -> float: 32 | """Return latitude value of the device.""" 33 | if not self.vehicle: 34 | return None 35 | 36 | return self.vehicle.location[0] 37 | 38 | @property 39 | def longitude(self) -> float: 40 | """Return longitude value of the device.""" 41 | if not self.vehicle: 42 | return None 43 | 44 | return self.vehicle.location[1] 45 | 46 | @property 47 | def source_type(self): 48 | """Return the source type, eg gps or router, of the device.""" 49 | return SourceType.GPS 50 | 51 | @property 52 | def icon(self): 53 | """Return the icon.""" 54 | return "mdi:car" 55 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/base.py: -------------------------------------------------------------------------------- 1 | from homeassistant.helpers.entity import Entity, DeviceInfo 2 | from homeassistant.core import callback 3 | from .const import DOMAIN 4 | 5 | class KamereonEntity(Entity): 6 | """Base class for all Kamereon car entities.""" 7 | 8 | _attr_has_entity_name = True 9 | 10 | def __init__(self, cooordinator, vehicle): 11 | """Initialize the entity.""" 12 | self.vehicle = vehicle 13 | self.coordinator = cooordinator 14 | 15 | async def async_added_to_hass(self) -> None: 16 | """When entity is added to hass.""" 17 | await super().async_added_to_hass() 18 | self.async_on_remove( 19 | self.coordinator.async_add_listener( 20 | self._handle_coordinator_update, None 21 | ) 22 | ) 23 | 24 | @callback 25 | def _handle_coordinator_update(self) -> None: 26 | self.async_write_ha_state() 27 | 28 | @property 29 | def icon(self): 30 | """Return the icon.""" 31 | return 'mdi:car' 32 | 33 | @property 34 | def _vehicle_name(self): 35 | return self.vehicle.nickname or self.vehicle.model_name 36 | 37 | @property 38 | def unique_id(self): 39 | """Return unique ID of the entity.""" 40 | # New unique ID format for multiple cars and multiple accounts 41 | if self.vehicle.session.unique_id: 42 | return f"{self.vehicle.session.unique_id}_{self.vehicle.vin}_{self._attr_translation_key}" 43 | 44 | return f"{self._vehicle_name}_{self._attr_translation_key}" 45 | 46 | @property 47 | def device_info(self): 48 | return DeviceInfo( 49 | identifiers={(DOMAIN, self.vehicle.session.tenant, self.vehicle.vin)}, 50 | name=self.vehicle.nickname or self.vehicle.model_name, 51 | manufacturer=self.vehicle.session.tenant.capitalize(), 52 | model=f"{self.vehicle.model_year} {self.vehicle.model_name}", 53 | hw_version=self.vehicle.model_code, 54 | serial_number=self.vehicle.vin, 55 | ) 56 | -------------------------------------------------------------------------------- /tests/test_binary_sensor.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from homeassistant.const import STATE_UNKNOWN 3 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 4 | from custom_components.nissan_connect.kamereon import ChargingStatus, PluggedStatus, LockStatus 5 | 6 | from custom_components.nissan_connect.binary_sensor import ( 7 | ChargingStatusEntity, 8 | PluggedStatusEntity, 9 | LockStatusEntity, 10 | ) 11 | 12 | @pytest.fixture 13 | def vehicle(): 14 | class Vehicle: 15 | def __init__(self): 16 | self.charging = None 17 | self.charging_speed = None 18 | self.battery_status_last_updated = None 19 | self.plugged_in = None 20 | self.plugged_in_time = None 21 | self.unplugged_time = None 22 | self.lock_status = None 23 | 24 | return Vehicle() 25 | 26 | @pytest.fixture 27 | def coordinator(hass): 28 | async def async_update_data(): 29 | return {} 30 | 31 | return DataUpdateCoordinator(hass, None, name="test", update_method=async_update_data) 32 | 33 | async def test_charging_status_entity(vehicle, coordinator): 34 | entity = ChargingStatusEntity(coordinator, vehicle) 35 | assert entity.is_on == STATE_UNKNOWN 36 | 37 | vehicle.charging = ChargingStatus.CHARGING 38 | assert entity.is_on is True 39 | 40 | vehicle.charging = ChargingStatus.NOT_CHARGING 41 | assert entity.is_on is False 42 | 43 | async def test_plugged_status_entity(vehicle, coordinator): 44 | entity = PluggedStatusEntity(coordinator, vehicle) 45 | assert entity.is_on == STATE_UNKNOWN 46 | 47 | vehicle.plugged_in = PluggedStatus.PLUGGED 48 | assert entity.is_on is True 49 | 50 | vehicle.plugged_in = PluggedStatus.NOT_PLUGGED 51 | assert entity.is_on is False 52 | 53 | async def test_lock_status_entity(vehicle, coordinator): 54 | entity = LockStatusEntity(coordinator, vehicle) 55 | assert entity.is_on is False 56 | 57 | vehicle.lock_status = LockStatus.LOCKED 58 | assert entity.is_on is False 59 | 60 | vehicle.lock_status = LockStatus.UNLOCKED 61 | assert entity.is_on is True 62 | -------------------------------------------------------------------------------- /tests/test_button.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import AsyncMock, MagicMock 3 | from homeassistant.helpers import entity_registry as er 4 | from custom_components.nissan_connect.const import DOMAIN, DATA_VEHICLES, DATA_COORDINATOR_POLL, DATA_COORDINATOR_FETCH, DATA_COORDINATOR_STATISTICS 5 | from custom_components.nissan_connect.kamereon.kamereon_const import Feature 6 | 7 | from custom_components.nissan_connect.button import ( 8 | async_setup_entry, 9 | ForceUpdateButton, 10 | HornLightsButtons, 11 | ChargeControlButtons, 12 | ) 13 | 14 | 15 | @pytest.fixture 16 | def mock_hass(): 17 | hass = MagicMock() 18 | hass.data = { 19 | DOMAIN: { 20 | 'test_account': { 21 | DATA_VEHICLES: { 22 | 'vehicle_1': MagicMock(features=[Feature.HORN_AND_LIGHTS, Feature.CHARGING_START]) 23 | }, 24 | DATA_COORDINATOR_POLL: MagicMock(), 25 | DATA_COORDINATOR_FETCH: MagicMock(), 26 | DATA_COORDINATOR_STATISTICS: MagicMock(), 27 | } 28 | } 29 | } 30 | return hass 31 | 32 | 33 | @pytest.fixture 34 | def mock_config(): 35 | return MagicMock(data={'email': 'test_account'}) 36 | 37 | 38 | @pytest.fixture 39 | def mock_async_add_entities(): 40 | return AsyncMock() 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_async_setup_entry(mock_hass, mock_config, mock_async_add_entities): 45 | await async_setup_entry(mock_hass, mock_config, mock_async_add_entities) 46 | assert mock_async_add_entities.call_count == 1 47 | entities = mock_async_add_entities.call_args[0][0] 48 | assert len(entities) == 4 49 | assert isinstance(entities[0], ForceUpdateButton) 50 | assert isinstance(entities[1], HornLightsButtons) 51 | assert isinstance(entities[2], HornLightsButtons) 52 | assert isinstance(entities[3], ChargeControlButtons) 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_force_update_button(): 57 | coordinator = AsyncMock() 58 | vehicle = MagicMock() 59 | hass = AsyncMock() 60 | stats_coordinator = MagicMock() 61 | 62 | button = ForceUpdateButton(coordinator, vehicle, hass, stats_coordinator) 63 | 64 | await button.async_press() 65 | vehicle.refresh.assert_called_once() 66 | coordinator.async_refresh.assert_called_once() 67 | 68 | 69 | def test_horn_lights_buttons(): 70 | coordinator = MagicMock() 71 | vehicle = MagicMock() 72 | button = HornLightsButtons( 73 | coordinator, vehicle, "flash_lights", "mdi:car-light-high", "lights") 74 | 75 | button.press() 76 | vehicle.control_horn_lights.assert_called_once_with('start', "lights") 77 | 78 | 79 | def test_charge_control_buttons(): 80 | coordinator = MagicMock() 81 | vehicle = MagicMock() 82 | button = ChargeControlButtons( 83 | coordinator, vehicle, "charge_start", "mdi:play", "start") 84 | 85 | button.press() 86 | vehicle.control_charging.assert_called_once_with("start") 87 | -------------------------------------------------------------------------------- /tests/test_climate.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import AsyncMock, MagicMock 3 | from homeassistant.components.climate.const import HVACMode, HVACAction as HASSHVACAction 4 | from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature 5 | from custom_components.nissan_connect.climate import KamereonClimate 6 | from custom_components.nissan_connect.kamereon.kamereon_const import Feature, HVACAction 7 | 8 | @pytest.fixture 9 | def mock_vehicle(): 10 | vehicle = MagicMock() 11 | vehicle.features = [Feature.CLIMATE_ON_OFF, Feature.TEMPERATURE] 12 | vehicle.hvac_status = False 13 | vehicle.internal_temperature = 22 14 | return vehicle 15 | 16 | @pytest.fixture 17 | def mock_coordinator(): 18 | return MagicMock() 19 | 20 | @pytest.fixture 21 | def mock_hass(): 22 | hass = MagicMock() 23 | hass.async_add_executor_job = AsyncMock() 24 | hass.async_create_task = AsyncMock() 25 | return hass 26 | 27 | @pytest.fixture 28 | def climate_entity(mock_coordinator, mock_vehicle, mock_hass): 29 | return KamereonClimate(mock_coordinator, mock_vehicle, mock_hass) 30 | 31 | def test_hvac_mode(climate_entity, mock_vehicle): 32 | mock_vehicle.hvac_status = True 33 | assert climate_entity.hvac_mode == HVACMode.HEAT_COOL 34 | mock_vehicle.hvac_status = False 35 | assert climate_entity.hvac_mode == HVACMode.OFF 36 | 37 | def test_current_temperature(climate_entity, mock_vehicle): 38 | mock_vehicle.internal_temperature = 22 39 | assert climate_entity.current_temperature == 22 40 | mock_vehicle.internal_temperature = None 41 | assert climate_entity.current_temperature is None 42 | 43 | def test_target_temperature(climate_entity): 44 | assert climate_entity.target_temperature == 20 45 | climate_entity.set_temperature(**{ATTR_TEMPERATURE: 25}) 46 | assert climate_entity.target_temperature == 25 47 | 48 | def test_hvac_action(climate_entity, mock_vehicle): 49 | mock_vehicle.hvac_status = True 50 | mock_vehicle.internal_temperature = 18 51 | climate_entity._target = 20 52 | assert climate_entity.hvac_action == HASSHVACAction.HEATING 53 | mock_vehicle.internal_temperature = 22 54 | assert climate_entity.hvac_action == HASSHVACAction.COOLING 55 | mock_vehicle.hvac_status = False 56 | assert climate_entity.hvac_action == HASSHVACAction.OFF 57 | 58 | @pytest.mark.asyncio 59 | async def test_async_set_hvac_mode(climate_entity, mock_hass, mock_vehicle): 60 | await climate_entity.async_set_hvac_mode(HVACMode.OFF) 61 | mock_hass.async_add_executor_job.assert_called_with(mock_vehicle.set_hvac_status, HVACAction.STOP) 62 | mock_hass.async_create_task.assert_called_once() 63 | 64 | await climate_entity.async_set_hvac_mode(HVACMode.HEAT_COOL) 65 | mock_hass.async_add_executor_job.assert_called_with(mock_vehicle.set_hvac_status, HVACAction.START, 20) 66 | assert mock_hass.async_create_task.call_count == 2 67 | 68 | @pytest.mark.asyncio 69 | async def test_async_turn_off(climate_entity): 70 | await climate_entity.async_turn_off() 71 | assert climate_entity.hvac_mode == HVACMode.OFF 72 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/button.py: -------------------------------------------------------------------------------- 1 | """Support for Kamereon cars.""" 2 | import logging 3 | import asyncio 4 | 5 | from homeassistant.components.button import ButtonEntity 6 | 7 | from .base import KamereonEntity 8 | from .kamereon import ChargingStatus, PluggedStatus, Feature 9 | from .const import DOMAIN, DATA_VEHICLES, DATA_COORDINATOR_POLL, DATA_COORDINATOR_FETCH, DATA_COORDINATOR_STATISTICS 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | async def async_setup_entry(hass, config, async_add_entities): 15 | account_id = config.data['email'] 16 | 17 | data = hass.data[DOMAIN][account_id][DATA_VEHICLES] 18 | coordinator = hass.data[DOMAIN][account_id][DATA_COORDINATOR_POLL] 19 | coordinator_fetch = hass.data[DOMAIN][account_id][DATA_COORDINATOR_FETCH] 20 | stats_coordinator = hass.data[DOMAIN][account_id][DATA_COORDINATOR_STATISTICS] 21 | 22 | entities = [] 23 | 24 | for vehicle in data: 25 | entities.append(ForceUpdateButton(coordinator_fetch, data[vehicle], hass, stats_coordinator)) 26 | if Feature.HORN_AND_LIGHTS in data[vehicle].features: 27 | entities += [ 28 | HornLightsButtons(coordinator, data[vehicle], "flash_lights", "mdi:car-light-high", "lights"), 29 | HornLightsButtons(coordinator, data[vehicle], "honk_horn", "mdi:bullhorn", "horn_lights") 30 | ] 31 | if Feature.CHARGING_START in data[vehicle].features: 32 | entities.append(ChargeControlButtons(coordinator, data[vehicle], "charge_start", "mdi:play", "start")) 33 | 34 | async_add_entities(entities, update_before_add=True) 35 | 36 | 37 | class ForceUpdateButton(KamereonEntity, ButtonEntity): 38 | _attr_translation_key = "update_data" 39 | 40 | def __init__(self, coordinator, vehicle, hass, stats_coordinator): 41 | KamereonEntity.__init__(self, coordinator, vehicle) 42 | self._hass = hass 43 | self.coordinator_statistics = stats_coordinator 44 | 45 | @property 46 | def icon(self): 47 | """Return the icon.""" 48 | return 'mdi:update' 49 | 50 | async def async_press(self): 51 | loop = asyncio.get_running_loop() 52 | 53 | await loop.run_in_executor(None, self.vehicle.refresh) 54 | await self.coordinator.async_refresh() 55 | 56 | class HornLightsButtons(KamereonEntity, ButtonEntity): 57 | def __init__(self, coordinator, vehicle, translation_key, icon, action): 58 | self._attr_translation_key = translation_key 59 | self._icon = icon 60 | self._action = action 61 | KamereonEntity.__init__(self, coordinator, vehicle) 62 | 63 | @property 64 | def icon(self): 65 | return self._icon 66 | 67 | def press(self): 68 | self.vehicle.control_horn_lights('start', self._action) 69 | 70 | class ChargeControlButtons(KamereonEntity, ButtonEntity): 71 | def __init__(self, coordinator, vehicle, translation_key, icon, action): 72 | self._attr_translation_key = translation_key 73 | self._icon = icon 74 | self._action = action 75 | KamereonEntity.__init__(self, coordinator, vehicle) 76 | 77 | @property 78 | def icon(self): 79 | return self._icon 80 | 81 | def press(self): 82 | self.vehicle.control_charging(self._action) 83 | 84 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Kamereon cars.""" 2 | from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass 3 | from homeassistant.const import STATE_UNKNOWN 4 | 5 | from .base import KamereonEntity 6 | from .kamereon import ChargingStatus, PluggedStatus, LockStatus, Feature 7 | from .const import DOMAIN, DATA_VEHICLES, DATA_COORDINATOR_FETCH 8 | 9 | async def async_setup_entry(hass, config, async_add_entities): 10 | """Set up the Kamereon sensors.""" 11 | account_id = config.data['email'] 12 | 13 | data = hass.data[DOMAIN][account_id][DATA_VEHICLES] 14 | coordinator = hass.data[DOMAIN][account_id][DATA_COORDINATOR_FETCH] 15 | 16 | entities = [] 17 | 18 | for vehicle in data: 19 | if Feature.BATTERY_STATUS in data[vehicle].features: 20 | entities += [ChargingStatusEntity(coordinator, data[vehicle]), 21 | PluggedStatusEntity(coordinator, data[vehicle])] 22 | if Feature.LOCK_STATUS_CHECK in data[vehicle].features: 23 | entities += [LockStatusEntity(coordinator, data[vehicle])] 24 | 25 | async_add_entities(entities, update_before_add=True) 26 | 27 | 28 | class ChargingStatusEntity(KamereonEntity, BinarySensorEntity): 29 | """Representation of charging status.""" 30 | _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING 31 | _attr_translation_key = "charging" 32 | 33 | @property 34 | def icon(self): 35 | """Return the icon.""" 36 | return 'mdi:{}'.format('battery-charging' if self.is_on else 'battery-off') 37 | 38 | @property 39 | def is_on(self): 40 | """Return True if the binary sensor is on.""" 41 | if self.vehicle.charging is None: 42 | return STATE_UNKNOWN 43 | return self.vehicle.charging is ChargingStatus.CHARGING 44 | 45 | @property 46 | def device_state_attributes(self): 47 | a = KamereonEntity.device_state_attributes.fget(self) 48 | a.update({ 49 | 'charging_speed': self.vehicle.charging_speed.value, 50 | 'last_updated': self.vehicle.battery_status_last_updated, 51 | }) 52 | return a 53 | 54 | 55 | class PluggedStatusEntity(KamereonEntity, BinarySensorEntity): 56 | """Representation of plugged status.""" 57 | _attr_device_class = BinarySensorDeviceClass.PLUG 58 | _attr_translation_key = "plugged" 59 | 60 | @property 61 | def icon(self): 62 | """Return the icon.""" 63 | return 'mdi:{}'.format('power-plug' if self.is_on else 'power-plug-off') 64 | 65 | @property 66 | def is_on(self): 67 | """Return True if the binary sensor is on.""" 68 | if self.vehicle.plugged_in is None: 69 | return STATE_UNKNOWN 70 | return self.vehicle.plugged_in is PluggedStatus.PLUGGED 71 | 72 | @property 73 | def device_state_attributes(self): 74 | a = KamereonEntity.device_state_attributes.fget(self) 75 | a.update({ 76 | 'plugged_in_time': self.vehicle.plugged_in_time, 77 | 'unplugged_time': self.vehicle.unplugged_time, 78 | 'last_updated': self.vehicle.battery_status_last_updated, 79 | }) 80 | return a 81 | 82 | 83 | class LockStatusEntity(KamereonEntity, BinarySensorEntity): 84 | _attr_device_class = BinarySensorDeviceClass.LOCK 85 | _attr_translation_key = "doors_locked" 86 | 87 | @property 88 | def icon(self): 89 | """Return the icon.""" 90 | return 'mdi:car-door-lock' if self.vehicle.lock_status == LockStatus.LOCKED else 'mdi:car-door-lock-open' 91 | 92 | @property 93 | def is_on(self): 94 | return self.vehicle.lock_status == LockStatus.UNLOCKED 95 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Tests for the config flow.""" 2 | from unittest import mock 3 | import pytest 4 | 5 | from custom_components.nissan_connect import config_flow 6 | from custom_components.nissan_connect.const import DOMAIN 7 | from homeassistant import data_entry_flow 8 | from custom_components.nissan_connect.const import DOMAIN, DEFAULT_REGION 9 | 10 | @pytest.fixture 11 | def mock_kamereon_session(): 12 | with mock.patch("custom_components.nissan_connect.config_flow.NCISession") as mock_session: 13 | yield mock_session 14 | 15 | async def test_step_account(hass): 16 | """Test the initialization of the form in the first step of the config flow.""" 17 | result = await hass.config_entries.flow.async_init( 18 | config_flow.DOMAIN, context={"source": "user"} 19 | ) 20 | 21 | expected = { 22 | 'type': 'form', 23 | 'flow_id': mock.ANY, 24 | 'handler': DOMAIN, 25 | 'step_id': 'user', 26 | 'data_schema': config_flow.USER_SCHEMA, 27 | 'errors': {}, 28 | 'description_placeholders': None, 29 | 'last_step': None, 30 | 'preview': None 31 | } 32 | 33 | assert expected == result 34 | 35 | async def test_step_user_init(hass): 36 | """Test the initialization of the form in the first step of the config flow.""" 37 | result = await hass.config_entries.flow.async_init( 38 | config_flow.DOMAIN, context={"source": "user"} 39 | ) 40 | 41 | expected = { 42 | 'type': 'form', 43 | 'flow_id': mock.ANY, 44 | 'handler': DOMAIN, 45 | 'step_id': 'user', 46 | 'data_schema': config_flow.USER_SCHEMA, 47 | 'errors': {}, 48 | 'description_placeholders': None, 49 | 'last_step': None, 50 | 'preview': None 51 | } 52 | 53 | assert expected == result 54 | 55 | async def test_step_user_submit(hass, mock_kamereon_session): 56 | """Test the user step with valid credentials.""" 57 | mock_kamereon_session.return_value.login.return_value = True 58 | 59 | result = await hass.config_entries.flow.async_init( 60 | config_flow.DOMAIN, context={"source": "user"} 61 | ) 62 | 63 | result = await hass.config_entries.flow.async_configure( 64 | result["flow_id"], 65 | { 66 | "email": "test@example.com", 67 | "password": "password123", 68 | "region": DEFAULT_REGION.lower(), 69 | "imperial_distance": False 70 | } 71 | ) 72 | 73 | assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY 74 | assert result["title"] == "test@example.com" 75 | assert result["data"] == { 76 | "email": "test@example.com", 77 | "password": "password123", 78 | "region": DEFAULT_REGION, 79 | "imperial_distance": False 80 | } 81 | 82 | async def test_step_user_invalid_auth(hass, mock_kamereon_session): 83 | """Test the user step with invalid credentials.""" 84 | mock_kamereon_session.return_value.login.side_effect = Exception("Invalid credentials") 85 | 86 | result = await hass.config_entries.flow.async_init( 87 | config_flow.DOMAIN, context={"source": "user"} 88 | ) 89 | 90 | result = await hass.config_entries.flow.async_configure( 91 | result["flow_id"], 92 | { 93 | "email": "test@example.com", 94 | "password": "wrongpassword", 95 | "region": DEFAULT_REGION.lower(), 96 | "imperial_distance": False 97 | } 98 | ) 99 | 100 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 101 | assert result["errors"] == {"base": "auth_error"} 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NissanConnect for Home Assistant 2 | 3 | An unofficial integration for interacting with NissanConnect vehicles in Europe. Based on the work of [mitchellrj](https://github.com/mitchellrj/kamereon-python) and [tobiaswk](https://github.com/Tobiaswk/dartnissanconnect). I have no affiliation with Nissan besides owning one of their cars. 4 | 5 | _Please note this integration is only for vehicles using the NissanConnect Services app, not NissanConnect EV or any other app._ 6 | 7 | If you find any bugs or would like to request a feature, please open an issue. 8 | 9 | ## Tested Vehicles 10 | This integration has been tested with the following vehicles: 11 | * Nissan Leaf (2022) [@dan-r] 12 | * Nissan Qashqai (2021) 13 | * Nissan Ariya 14 | * Nissan X-Trail (2024) 15 | * Nissan Juke (2021) 16 | 17 | ## Supported Regions 18 | * Europe 19 | 20 | Currently only Nissan vehicles within Europe are supported. 21 | 22 | ### North America 23 | The API used in North America is completely separate to Europe and it appears that Nissan USA are [a lot more hostile](https://tobis.dk/blog/the-farce-of-nissanconnect-north-america/) towards third-party access. Any future US support would rely on library support (such as [dartnissanconnectna](https://gitlab.com/tobiaswkjeldsen/dartnissanconnectna)) or someone in North America maintaining that side of things. If you're interested, get in touch! 24 | 25 | ## Installation 26 | 27 | ### HACS 28 | 29 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=dan-r&repository=HomeAssistant-NissanConnect&category=integration) 30 | 31 | This is the recommended installation method. 32 | 1. Search for and install the **NissanConnect [EU]** integration from HACS 33 | 2. Restart Home Assistant 34 | 35 | ### Manual 36 | 1. Download the [latest release](https://github.com/dan-r/HomeAssistant-NissanConnect/releases) 37 | 2. Copy the contents of `custom_components` into the `/custom_components` directory of your Home Assistant installation 38 | 3. Restart Home Assistant 39 | 40 | 41 | ## Setup 42 | From the Home Assistant Integrations page, search for and add the Nissan Connect integration. 43 | 44 | ## Update Time 45 | Terminology used for this integration: 46 | * Polling - the car is woken up and new status is reported. This is disabled by default, but can be enabled by setting the polling interval to a non-zero value 47 | * Update - data is fetched from Nissan but the car is not woken up 48 | 49 | Following the model of leaf2mqtt, this integration can be set to use a different polling time when plugged in. When HVAC is turned on the polling time always drops to once per minute. 50 | 51 | To prevent excessive 12v battery drain when plugged in but not charging for extended periods of time, the polling interval reverts to the standard interval after 4 consecutive updates show the car as plugged in but not charging. 52 | This logic was added to give the benefit of quicker response times on the charging status binary sensor, which can be especially useful when charging with load-balanced or 'smart' chargers. 53 | 54 | ## Translations 55 | Translations are provided for the following languages. If you are a native speaker and spot any mistakes, please let me know. 56 | * English 57 | * Danish 58 | * Dutch 59 | * French 60 | * German 61 | * Italian 62 | * Norwegian 63 | * Polish 64 | * Portuguese 65 | * Russian 66 | * Spanish 67 | 68 | ## Entities 69 | This integration exposes the following entities. Please note that entities will only be shown if the functionality is supported by your car. 70 | 71 | * Binary Sensors 72 | * Car Plugged In (EV Only) 73 | * Car Charging (EV Only) 74 | * Doors Locked 75 | * Sensors 76 | * Battery Level 77 | * Charge Time 78 | * Internal Temperature 79 | * External Temperature 80 | * Range (EV Only) 81 | * Odometer 82 | * Daily Distance 83 | * Daily Trips 84 | * Daily Efficiency (EV Only) 85 | * Monthly Distance 86 | * Monthly Trips 87 | * Monthly Efficiency (EV Only) 88 | * Climate 89 | * Device Tracker 90 | * Buttons 91 | * Update Data 92 | * Flash Lights 93 | * Honk Horn 94 | * Start Charge 95 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import timedelta 3 | from .kamereon import NCISession 4 | from .coordinator import KamereonFetchCoordinator, KamereonPollCoordinator, StatisticsCoordinator 5 | from .const import * 6 | 7 | _LOGGER = logging.getLogger(__name__) 8 | 9 | 10 | async def async_setup(hass, config) -> bool: 11 | return True 12 | 13 | 14 | async def async_update_listener(hass, entry): 15 | """Handle options flow credentials update.""" 16 | config = entry.data 17 | account_id = config['email'] 18 | 19 | # Loop each vehicle and update its session with the new credentials 20 | for vehicle in hass.data[DOMAIN][account_id][DATA_VEHICLES]: 21 | await hass.async_add_executor_job(hass.data[DOMAIN][account_id][DATA_VEHICLES][vehicle].session.login, 22 | config.get("email"), 23 | config.get("password") 24 | ) 25 | 26 | # Update intervals for coordinators 27 | hass.data[DOMAIN][account_id][DATA_COORDINATOR_STATISTICS].update_interval = timedelta(minutes=config.get("interval_statistics", DEFAULT_INTERVAL_STATISTICS)) 28 | hass.data[DOMAIN][account_id][DATA_COORDINATOR_FETCH].update_interval = timedelta(minutes=config.get("interval_fetch", DEFAULT_INTERVAL_FETCH)) 29 | 30 | # Refresh fetch coordinator 31 | await hass.data[DOMAIN][account_id][DATA_COORDINATOR_FETCH].async_refresh() 32 | 33 | 34 | async def async_setup_entry(hass, entry): 35 | """This is called from the config flow.""" 36 | account_id = entry.data['email'] 37 | 38 | hass.data.setdefault(DOMAIN, {}) 39 | hass.data[DOMAIN].setdefault(account_id, {}) 40 | 41 | config = dict(entry.data) 42 | 43 | kamereon_session = NCISession( 44 | region=config["region"], 45 | unique_id=entry.unique_id 46 | ) 47 | 48 | data = hass.data[DOMAIN][account_id] = { 49 | DATA_VEHICLES: {} 50 | } 51 | 52 | _LOGGER.info("Logging in to service") 53 | await hass.async_add_executor_job(kamereon_session.login, 54 | config.get("email"), 55 | config.get("password") 56 | ) 57 | 58 | _LOGGER.debug("Finding vehicles") 59 | for vehicle in await hass.async_add_executor_job(kamereon_session.fetch_vehicles): 60 | await hass.async_add_executor_job(vehicle.fetch_all) 61 | if vehicle.vin not in data[DATA_VEHICLES]: 62 | data[DATA_VEHICLES][vehicle.vin] = vehicle 63 | 64 | coordinator = data[DATA_COORDINATOR_FETCH] = KamereonFetchCoordinator(hass, config) 65 | poll_coordinator = data[DATA_COORDINATOR_POLL] = KamereonPollCoordinator(hass, config) 66 | stats_coordinator = data[DATA_COORDINATOR_STATISTICS] = StatisticsCoordinator( 67 | hass, config) 68 | 69 | _LOGGER.debug("Initialising entities") 70 | await hass.config_entries.async_forward_entry_setups(entry, ENTITY_TYPES) 71 | 72 | # Init fetch and state coordinators 73 | await coordinator.async_config_entry_first_refresh() 74 | await stats_coordinator.async_config_entry_first_refresh() 75 | 76 | # Init poll coordinator and ensure it runs 77 | entry.async_on_unload( 78 | poll_coordinator.async_add_listener( 79 | lambda *args: None, None 80 | ) 81 | ) 82 | await poll_coordinator.async_config_entry_first_refresh() 83 | 84 | entry.async_on_unload(entry.add_update_listener(async_update_listener)) 85 | 86 | return True 87 | 88 | 89 | async def async_unload_entry(hass, entry): 90 | """Unload a config entry.""" 91 | 92 | return await hass.config_entries.async_unload_platforms(entry, ENTITY_TYPES) 93 | 94 | 95 | async def async_migrate_entry(hass, config_entry) -> bool: 96 | """Migrate old entry.""" 97 | # Version number has gone backwards 98 | if CONFIG_VERSION < config_entry.version: 99 | _LOGGER.error( 100 | "Backwards migration not possible. Please update the integration.") 101 | return False 102 | 103 | # Version number has gone up 104 | if config_entry.version < CONFIG_VERSION: 105 | _LOGGER.debug("Migrating from version %s", config_entry.version) 106 | new_data = config_entry.data 107 | 108 | config_entry.version = CONFIG_VERSION 109 | hass.config_entries.async_update_entry(config_entry, data=new_data) 110 | 111 | _LOGGER.debug("Migration to version %s successful", 112 | config_entry.version) 113 | 114 | return True 115 | -------------------------------------------------------------------------------- /tests/test_sensor.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import AsyncMock, MagicMock 3 | from homeassistant.const import PERCENTAGE, UnitOfTemperature, UnitOfLength, UnitOfTime 4 | from custom_components.nissan_connect.base import KamereonEntity 5 | from custom_components.nissan_connect.kamereon import ChargingSpeed, Feature 6 | 7 | from custom_components.nissan_connect.sensor import ( 8 | BatteryLevelSensor, 9 | InternalTemperatureSensor, 10 | ExternalTemperatureSensor, 11 | RangeSensor, 12 | OdometerSensor, 13 | StatisticSensor, 14 | ChargeTimeRequiredSensor, 15 | TimestampSensor, 16 | async_setup_entry 17 | ) 18 | 19 | @pytest.fixture 20 | def mock_hass(): 21 | hass = MagicMock() 22 | hass.data = { 23 | 'nissan_connect': { 24 | 'test_account': { 25 | 'vehicles': { 26 | 'test_vehicle': MagicMock( 27 | battery_level=80, 28 | internal_temperature=22.5, 29 | external_temperature=15.0, 30 | range_hvac_on=100, 31 | range_hvac_off=120, 32 | total_mileage=5000, 33 | charge_time_required_to_full={ChargingSpeed.NORMAL: 60, ChargingSpeed.FAST: 30, ChargingSpeed.ADAPTIVE: None}, 34 | features=[Feature.BATTERY_STATUS, Feature.DRIVING_JOURNEY_HISTORY] 35 | ) 36 | }, 37 | 'coordinator_fetch': AsyncMock(), 38 | 'coordinator_statistics': AsyncMock() 39 | } 40 | } 41 | } 42 | return hass 43 | 44 | @pytest.fixture 45 | def mock_config(): 46 | return MagicMock(data={'email': 'test_account', 'imperial_distance': False}) 47 | 48 | @pytest.fixture 49 | def mock_async_add_entities(): 50 | return AsyncMock() 51 | 52 | @pytest.mark.asyncio 53 | async def test_async_setup_entry(mock_hass, mock_config, mock_async_add_entities): 54 | await async_setup_entry(mock_hass, mock_config, mock_async_add_entities) 55 | assert mock_async_add_entities.call_count == 1 56 | entities = mock_async_add_entities.call_args[0][0] 57 | assert len(entities) > 0 58 | 59 | def test_battery_level_sensor(mock_hass): 60 | vehicle = mock_hass.data['nissan_connect']['test_account']['vehicles']['test_vehicle'] 61 | coordinator = mock_hass.data['nissan_connect']['test_account']['coordinator_fetch'] 62 | sensor = BatteryLevelSensor(coordinator, vehicle) 63 | assert sensor.state == 80 64 | 65 | def test_internal_temperature_sensor(mock_hass): 66 | vehicle = mock_hass.data['nissan_connect']['test_account']['vehicles']['test_vehicle'] 67 | coordinator = mock_hass.data['nissan_connect']['test_account']['coordinator_fetch'] 68 | sensor = InternalTemperatureSensor(coordinator, vehicle) 69 | assert sensor.native_value == 22.5 70 | 71 | def test_external_temperature_sensor(mock_hass): 72 | vehicle = mock_hass.data['nissan_connect']['test_account']['vehicles']['test_vehicle'] 73 | coordinator = mock_hass.data['nissan_connect']['test_account']['coordinator_fetch'] 74 | sensor = ExternalTemperatureSensor(coordinator, vehicle) 75 | assert sensor.native_value == 15.0 76 | 77 | def test_range_sensor(mock_hass): 78 | vehicle = mock_hass.data['nissan_connect']['test_account']['vehicles']['test_vehicle'] 79 | coordinator = mock_hass.data['nissan_connect']['test_account']['coordinator_fetch'] 80 | sensor = RangeSensor(coordinator, vehicle, True, False) 81 | assert sensor.native_value == 100 82 | 83 | def test_odometer_sensor(mock_hass): 84 | vehicle = mock_hass.data['nissan_connect']['test_account']['vehicles']['test_vehicle'] 85 | coordinator = mock_hass.data['nissan_connect']['test_account']['coordinator_fetch'] 86 | sensor = OdometerSensor(coordinator, vehicle, False) 87 | sensor.async_write_ha_state = MagicMock() 88 | sensor._handle_coordinator_update() 89 | assert sensor.native_value == 5000 90 | 91 | def test_charge_time_required_sensor(mock_hass): 92 | vehicle = mock_hass.data['nissan_connect']['test_account']['vehicles']['test_vehicle'] 93 | coordinator = mock_hass.data['nissan_connect']['test_account']['coordinator_fetch'] 94 | sensor = ChargeTimeRequiredSensor(coordinator, vehicle, ChargingSpeed.NORMAL) 95 | assert sensor.native_value == 60 96 | 97 | def test_timestamp_sensor(mock_hass): 98 | vehicle = mock_hass.data['nissan_connect']['test_account']['vehicles']['test_vehicle'] 99 | coordinator = mock_hass.data['nissan_connect']['test_account']['coordinator_fetch'] 100 | sensor = TimestampSensor(coordinator, vehicle, 'battery_status_last_updated', 'last_updated', 'mdi:clock-time-eleven-outline') 101 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/translations/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "NissanConnect", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "description": "Configure a sua conta NissanConnect.", 7 | "data": { 8 | "email": "Endereço de Email", 9 | "password": "Palavra-passe", 10 | "interval": "Intervalo de consulta (minutos)", 11 | "interval_charging": "Intervalo de consulta durante o carregamento (minutos)", 12 | "interval_statistics": "Intervalo de atualização para estatísticas diárias/mensais (minutos)", 13 | "interval_fetch": "Intervalo de atualização (minutos)", 14 | "region": "Região", 15 | "imperial_distance": "Usar unidades de distância imperiais" 16 | }, 17 | "data_description": { 18 | "interval_charging": "O carro será acordado e novos dados serão solicitados em cada intervalo de consulta.", 19 | "interval_statistics": "Nos intervalos de atualização, os dados mais recentes serão obtidos da Nissan, mas o carro não será acordado." 20 | } 21 | } 22 | }, 23 | "error": { 24 | "auth_error": "Credenciais inválidas fornecidas.", 25 | "region_error": "Região inválida fornecida. Por favor, forneça uma região válida." 26 | }, 27 | "abort": {} 28 | }, 29 | "options": { 30 | "step": { 31 | "init": { 32 | "description": "Atualize as suas informações da conta NissanConnect.", 33 | "data": { 34 | "email": "Endereço de Email", 35 | "password": "Palavra-passe", 36 | "interval": "Intervalo de consulta (minutos)", 37 | "interval_charging": "Intervalo de consulta durante o carregamento (minutos)", 38 | "interval_statistics": "Intervalo de atualização para estatísticas diárias/mensais (minutos)", 39 | "interval_fetch": "Intervalo de atualização (minutos)", 40 | "imperial_distance": "Usar unidades de distância imperiais" 41 | }, 42 | "data_description": { 43 | "password": "Se não estiver a alterar as suas credenciais, deixe o campo da palavra-passe vazio.", 44 | "interval_charging": "O carro será acordado e novos dados serão solicitados em cada intervalo de consulta.", 45 | "interval_statistics": "Nos intervalos de atualização, os dados mais recentes serão obtidos da Nissan, mas o carro não será acordado." 46 | } 47 | } 48 | }, 49 | "error": { 50 | "auth_error": "Credenciais inválidas fornecidas." 51 | }, 52 | "abort": {} 53 | }, 54 | "issues": {}, 55 | "entity": { 56 | "binary_sensor": { 57 | "charging": { 58 | "name": "A carregar" 59 | }, 60 | "doors_locked": { 61 | "name": "Portas trancadas" 62 | }, 63 | "plugged": { 64 | "name": "Ligado" 65 | } 66 | }, 67 | "button": { 68 | "flash_lights": { 69 | "name": "Piscar luzes" 70 | }, 71 | "honk_horn": { 72 | "name": "Buzinar" 73 | }, 74 | "update_data": { 75 | "name": "Atualizar dados" 76 | }, 77 | "charge_start": { 78 | "name": "Iniciar carregamento" 79 | } 80 | }, 81 | "climate": { 82 | "climate": { 83 | "name": "Climatização" 84 | } 85 | }, 86 | "device_tracker": { 87 | "location": { 88 | "name": "Localização" 89 | } 90 | }, 91 | "sensor": { 92 | "battery_level": { 93 | "name": "Nível de bateria" 94 | }, 95 | "internal_temperature": { 96 | "name": "Temperatura interna" 97 | }, 98 | "external_temperature": { 99 | "name": "Temperatura externa" 100 | }, 101 | "range_ac_on": { 102 | "name": "Autonomia (AC ligado)" 103 | }, 104 | "range_ac_off": { 105 | "name": "Autonomia (AC desligado)" 106 | }, 107 | "odometer": { 108 | "name": "Odómetro" 109 | }, 110 | "charge_time_3kw": { 111 | "name": "Tempo de carregamento (3kW)" 112 | }, 113 | "charge_time_6kw": { 114 | "name": "Tempo de carregamento (6kW)" 115 | }, 116 | "last_updated": { 117 | "name": "Última atualização" 118 | }, 119 | "daily_distance": { 120 | "name": "Distância diária" 121 | }, 122 | "daily_efficiency": { 123 | "name": "Eficiência diária" 124 | }, 125 | "daily_trips": { 126 | "name": "Viagens diárias" 127 | }, 128 | "monthly_distance": { 129 | "name": "Distância mensal" 130 | }, 131 | "monthly_efficiency": { 132 | "name": "Eficiência mensal" 133 | }, 134 | "monthly_trips": { 135 | "name": "Viagens mensais" 136 | } 137 | } 138 | }, 139 | "selector": { 140 | "region": { 141 | "options": { 142 | "eu": "Europa" 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "NissanConnect", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "description": "Configure your NissanConnect account.", 7 | "data": { 8 | "email": "Email address", 9 | "password": "Password", 10 | "interval": "Polling interval (minutes)", 11 | "interval_charging": "Polling interval while charging (minutes)", 12 | "interval_statistics": "Update interval for daily/monthly statistics (minutes)", 13 | "interval_fetch": "Update interval (minutes)", 14 | "region": "Region", 15 | "imperial_distance": "Use imperial distance units (miles)" 16 | }, 17 | "data_description": { 18 | "interval_charging": "The car will be woken up and new data requested at every polling interval.", 19 | "interval_statistics": "On update intervals, the latest data will be fetched from Nissan but the car will not be woken up." 20 | } 21 | } 22 | }, 23 | "error": { 24 | "auth_error": "Invalid credentials provided.", 25 | "region_error": "Invalid region provided. Please provide a valid region." 26 | }, 27 | "abort": {} 28 | }, 29 | "options": { 30 | "step": { 31 | "init": { 32 | "description": "Update your NissanConnect account information.", 33 | "data": { 34 | "email": "Email address", 35 | "password": "Password", 36 | "interval": "Polling interval (minutes)", 37 | "interval_charging": "Polling interval while charging (minutes)", 38 | "interval_statistics": "Update interval for daily/monthly statistics (minutes)", 39 | "interval_fetch": "Update interval (minutes)", 40 | "imperial_distance": "Use imperial distance units (miles)" 41 | }, 42 | "data_description": { 43 | "password": "If you are not changing your credentials, leave the password field empty.", 44 | "interval_charging": "The car will be woken up and new data requested at every polling interval. 0 = Polling disabled", 45 | "interval_statistics": "On update intervals, the latest data will be fetched from Nissan but the car will not be woken up." 46 | } 47 | } 48 | }, 49 | "error": { 50 | "auth_error": "Invalid credentials provided." 51 | }, 52 | "abort": {} 53 | }, 54 | "issues": {}, 55 | "entity": { 56 | "binary_sensor": { 57 | "charging": { 58 | "name": "Charging" 59 | }, 60 | "doors_locked": { 61 | "name": "Doors Locked" 62 | }, 63 | "plugged": { 64 | "name": "Plugged In" 65 | } 66 | }, 67 | "button": { 68 | "flash_lights": { 69 | "name": "Flash Lights" 70 | }, 71 | "honk_horn": { 72 | "name": "Honk Horn" 73 | }, 74 | "update_data": { 75 | "name": "Update Data" 76 | }, 77 | "charge_start": { 78 | "name": "Start Charge" 79 | } 80 | }, 81 | "climate": { 82 | "climate": { 83 | "name": "Climate" 84 | } 85 | }, 86 | "device_tracker": { 87 | "location": { 88 | "name": "Location" 89 | } 90 | }, 91 | "sensor": { 92 | "battery_level": { 93 | "name": "Battery Level" 94 | }, 95 | "internal_temperature": { 96 | "name": "Internal Temperature" 97 | }, 98 | "external_temperature": { 99 | "name": "External Temperature" 100 | }, 101 | "range_ac_on": { 102 | "name": "Range (AC On)" 103 | }, 104 | "range_ac_off": { 105 | "name": "Range (AC Off)" 106 | }, 107 | "odometer": { 108 | "name": "Odometer" 109 | }, 110 | "charge_time_3kw": { 111 | "name": "Charge Time (3kW)" 112 | }, 113 | "charge_time_6kw": { 114 | "name": "Charge Time (6kW)" 115 | }, 116 | "charge_time_adaptive": { 117 | "name": "Charge Time (adaptive)" 118 | }, 119 | "last_updated": { 120 | "name": "Last Updated" 121 | }, 122 | "daily_distance": { 123 | "name": "Daily Distance" 124 | }, 125 | "daily_efficiency": { 126 | "name": "Daily Efficiency" 127 | }, 128 | "daily_trips": { 129 | "name": "Daily Trips" 130 | }, 131 | "monthly_distance": { 132 | "name": "Monthly Distance" 133 | }, 134 | "monthly_efficiency": { 135 | "name": "Monthly Efficiency" 136 | }, 137 | "monthly_trips": { 138 | "name": "Monthly Trips" 139 | } 140 | } 141 | }, 142 | "selector": { 143 | "region": { 144 | "options": { 145 | "eu": "Europe" 146 | } 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/translations/no.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "NissanConnect", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "description": "Sett opp din NissanConnect konto.", 7 | "data": { 8 | "email": "E-postadresse", 9 | "password": "Passord", 10 | "interval": "Oppdateringsfrekvens i HA (i minutter)", 11 | "interval_charging": "Oppdateringsfrekvens under lading for henting av data (i minutter)", 12 | "interval_statistics": "Oppdateringsfrekvens for daglig / månedlig statistikk (i minutter)", 13 | "interval_fetch": "Oppdateringsfrekvens for henting av data (i minutter)", 14 | "region": "Region", 15 | "imperial_distance": "Bruk Britiske/Amerikanske (Imperial) måleenheter" 16 | }, 17 | "data_description": { 18 | "interval_charging": "Bilen vil vekkes ved hver spørring, som angitt i oppdateringsfrekvensen", 19 | "interval_statistics": "Ved oppdatering av statistikk blir data forespurt fra Nissan, men bilen blir ikke vekket." 20 | } 21 | } 22 | }, 23 | "error": { 24 | "auth_error": "Ugyldig innloggingsinformasjon", 25 | "region_error": "Ugyldig region angitt" 26 | }, 27 | "abort": {} 28 | }, 29 | "options": { 30 | "step": { 31 | "init": { 32 | "description": "Oppdater NissanConnect kontoopplysninger", 33 | "data": { 34 | "email": "E-postadresse", 35 | "password": "Passord", 36 | "interval": "Oppdateringsfrekvens i HA (i minutter)", 37 | "interval_charging": "Oppdateringsfrekvens under lading for henting av data (i minutter)", 38 | "interval_statistics": "Oppdateringsfrekvens for daglig / månedlig statistikk (i minutter)", 39 | "interval_fetch": "Oppdateringsfrekvens for henting av data (i minutter)", 40 | "imperial_distance": "Bruk Britiske/Amerikanske (Imperial) måleenheter" 41 | }, 42 | "data_description": { 43 | "password": "La stå tomt hvis du ikke skal oppdatere innloggingsinformasjon", 44 | "interval_charging": "Bilen vil vekkes ved hver spørring, som angitt i oppdateringsfrekvensen", 45 | "interval_statistics": "Ved oppdatering av statistikk blir data forespurt fra Nissan, men bilen blir ikke vekket." 46 | } 47 | } 48 | }, 49 | "error": { 50 | "auth_error": "Ugyldig innloggingsinformasjon" 51 | }, 52 | "abort": {} 53 | }, 54 | "issues": {}, 55 | "entity": { 56 | "binary_sensor": { 57 | "charging": { 58 | "name": "Lader" 59 | }, 60 | "doors_locked": { 61 | "name": "Låst" 62 | }, 63 | "plugged": { 64 | "name": "Plugget inn" 65 | } 66 | }, 67 | "button": { 68 | "flash_lights": { 69 | "name": "Blink med lys" 70 | }, 71 | "honk_horn": { 72 | "name": "Lydhorn" 73 | }, 74 | "update_data": { 75 | "name": "Oppdater data" 76 | }, 77 | "charge_start": { 78 | "name": "Start lading" 79 | } 80 | }, 81 | "climate": { 82 | "climate": { 83 | "name": "Klima" 84 | } 85 | }, 86 | "device_tracker": { 87 | "location": { 88 | "name": "Lokasjon" 89 | } 90 | }, 91 | "sensor": { 92 | "battery_level": { 93 | "name": "Batteri" 94 | }, 95 | "internal_temperature": { 96 | "name": "Kupétemperatur" 97 | }, 98 | "external_temperature": { 99 | "name": "Utendørs temperature" 100 | }, 101 | "range_ac_on": { 102 | "name": "Est. rekkevidde med klimaanlegg (på)" 103 | }, 104 | "range_ac_off": { 105 | "name": "Est. rekkevidde uten klimaanlegg (av)" 106 | }, 107 | "odometer": { 108 | "name": "Odometer" 109 | }, 110 | "charge_time_3kw": { 111 | "name": "Ladetid (3kW)" 112 | }, 113 | "charge_time_6kw": { 114 | "name": "Ladetid (6kW)" 115 | }, 116 | "last_updated": { 117 | "name": "Sist oppdatert" 118 | }, 119 | "daily_distance": { 120 | "name": "Daglig lengde" 121 | }, 122 | "daily_efficiency": { 123 | "name": "Daglig effektivitet" 124 | }, 125 | "daily_trips": { 126 | "name": "Daglige turer" 127 | }, 128 | "monthly_distance": { 129 | "name": "Månedlig lengde" 130 | }, 131 | "monthly_efficiency": { 132 | "name": "Månedlig effektivitet" 133 | }, 134 | "monthly_trips": { 135 | "name": "Månedlige turer" 136 | } 137 | } 138 | }, 139 | "selector": { 140 | "region": { 141 | "options": { 142 | "eu": "Europa" 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/translations/dk.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "NissanConnect", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "description": "Konfigurer din NissanConnect konto.", 7 | "data": { 8 | "email": "Email adresse", 9 | "password": "Adgangskode", 10 | "interval": "Opdateringsfrekvens (minutter)", 11 | "interval_charging": "Opdateringsfrekvens under ladning(minutter)", 12 | "interval_statistics": "Opdateringsfrekvens for daglige/månedlige statistikker (minutter)", 13 | "interval_fetch": "Opdateringsfrekvens for hentning (minutter)", 14 | "region": "Region", 15 | "imperial_distance": "Brug Engelske afstands enheder" 16 | }, 17 | "data_description": { 18 | "interval_charging": "Bilen vil blive vækket, og der anmodes om nye data j.f. opdateringsintervallet.", 19 | "interval_statistics": "De seneste data vil blive hentet fra Nissan j.f. opdateringsintervallet, men bilen bliver ikke vækket." 20 | } 21 | } 22 | }, 23 | "error": { 24 | "auth_error": "Ugyldige legitimationsoplysninger er angivet.", 25 | "region_error": "Ugyldig region angivet. Angiv venligst en gyldig region." 26 | }, 27 | "abort": {} 28 | }, 29 | "options": { 30 | "step": { 31 | "init": { 32 | "description": "Opdater dine NissanConnect kontooplysninger.", 33 | "data": { 34 | "email": "Email adresse", 35 | "password": "Adgangskode", 36 | "interval": "Opdateringsfrekvens (minutter)", 37 | "interval_charging": "Opdateringsfrekvens under ladning (minutter)", 38 | "interval_statistics": "Opdateringsfrekvens for daglige/månedlige statistikker (minutter)", 39 | "interval_fetch": "Opdateringsfrekvens for hentning (minutter)", 40 | "imperial_distance": "Brug Engelske afstands enheder" 41 | }, 42 | "data_description": { 43 | "password": "Hvis du ikke ændrer dine legitimationsoplysninger, så efterlad adgangskodefeltet tomt.", 44 | "interval_charging": "Bilen vil blive vækket, og der anmodes om nye data j.f. opdateringsintervallet.", 45 | "interval_statistics": "De seneste data vil blive hentet fra Nissan j.f. opdateringsintervallet, men bilen bliver ikke vækket." 46 | } 47 | } 48 | }, 49 | "error": { 50 | "auth_error": "Ugyldige legitimationsoplysninger er angivet." 51 | }, 52 | "abort": {} 53 | }, 54 | "issues": {}, 55 | "entity": { 56 | "binary_sensor": { 57 | "charging": { 58 | "name": "Lader" 59 | }, 60 | "doors_locked": { 61 | "name": "Døre låst" 62 | }, 63 | "plugged": { 64 | "name": "Tilsluttet" 65 | } 66 | }, 67 | "button": { 68 | "flash_lights": { 69 | "name": "Blink med lys" 70 | }, 71 | "honk_horn": { 72 | "name": "Brug hornet (x6)" 73 | }, 74 | "update_data": { 75 | "name": "Opdater data" 76 | }, 77 | "charge_start": { 78 | "name": "Start ladning" 79 | } 80 | }, 81 | "climate": { 82 | "climate": { 83 | "name": "Klimaanlæg" 84 | } 85 | }, 86 | "device_tracker": { 87 | "location": { 88 | "name": "Lokation" 89 | } 90 | }, 91 | "sensor": { 92 | "battery_level": { 93 | "name": "Batteri niveau" 94 | }, 95 | "internal_temperature": { 96 | "name": "Kabine temperatur" 97 | }, 98 | "external_temperature": { 99 | "name": "Ude temperatur" 100 | }, 101 | "range_ac_on": { 102 | "name": "Rækkevidde (AC tændt)" 103 | }, 104 | "range_ac_off": { 105 | "name": "Rækkevidde (AC slukket)" 106 | }, 107 | "odometer": { 108 | "name": "Kilometerstand" 109 | }, 110 | "charge_time_3kw": { 111 | "name": "Ladetid (3kW)" 112 | }, 113 | "charge_time_6kw": { 114 | "name": "Ladetid (6kW)" 115 | }, 116 | "last_updated": { 117 | "name": "Sidst opdateret" 118 | }, 119 | "daily_distance": { 120 | "name": "Daglig distance" 121 | }, 122 | "daily_efficiency": { 123 | "name": "Daglig effektivitet" 124 | }, 125 | "daily_trips": { 126 | "name": "Daglige ture" 127 | }, 128 | "monthly_distance": { 129 | "name": "Månedlig distance" 130 | }, 131 | "monthly_efficiency": { 132 | "name": "Månedlig effektivitet" 133 | }, 134 | "monthly_trips": { 135 | "name": "Månedlig ture" 136 | } 137 | } 138 | }, 139 | "selector": { 140 | "region": { 141 | "options": { 142 | "eu": "Europa" 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/translations/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "NissanConnect", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "description": "Skonfiguruj konto NissanConnect.", 7 | "data": { 8 | "email": "Adres email", 9 | "password": "Hasło", 10 | "interval": "Interwał odpytywania (minuty)", 11 | "interval_charging": "Interwał odpytywania podczas ładowania (minuty)", 12 | "interval_statistics": "Interwał aktualizacji dziennych/miesięcznych statystyk (minuty)", 13 | "interval_fetch": "Interwał aktualizacji (minuty)", 14 | "region": "Region", 15 | "imperial_distance": "Używaj jednostek imperialnych" 16 | }, 17 | "data_description": { 18 | "interval_charging": "Samochód zostanie obudzony i poproszony o nowe dane co każdy interwał odpytywania.", 19 | "interval_statistics": "Co każdy interwał aktualizacji najnowsze dane zostaną pobrane z serwerów Nissana ale samochód nie zostanie obudzony." 20 | } 21 | } 22 | }, 23 | "error": { 24 | "auth_error": "Podane dane logowania są niepoprawne.", 25 | "region_error": "Niepoprawny region. Proszę podać poprawny region." 26 | }, 27 | "abort": {} 28 | }, 29 | "options": { 30 | "step": { 31 | "init": { 32 | "description": "Zaktualizuj dane konta NissanConnect.", 33 | "data": { 34 | "email": "Adres email", 35 | "password": "Hasło", 36 | "interval": "Interwał odpytywania (minuty)", 37 | "interval_charging": "Interwał odpytywania podczas ładowania (minuty)", 38 | "interval_statistics": "Interwał aktualizacji dziennych/miesięcznych statystyk (minuty)", 39 | "interval_fetch": "Interwał aktualizacji (minuty)", 40 | "imperial_distance": "Używaj jednostek imperialnych" 41 | }, 42 | "data_description": { 43 | "password": "Jeżeli nie zmieniasz danych logowania pozostaw hasło puste.", 44 | "interval_charging": "Samochód zostanie obudzony i poproszony o nowe dane co każdy interwał odpytywania.", 45 | "interval_statistics": "Co każdy interwał aktualizacji najnowsze dane zostaną pobrane z serwerów Nissana ale samochód nie zostanie obudzony." 46 | } 47 | } 48 | }, 49 | "error": { 50 | "auth_error": "Podane dane logowania są niepoprawne." 51 | }, 52 | "abort": {} 53 | }, 54 | "issues": {}, 55 | "entity": { 56 | "binary_sensor": { 57 | "charging": { 58 | "name": "Ładowanie" 59 | }, 60 | "doors_locked": { 61 | "name": "Drzwi zamknięte" 62 | }, 63 | "plugged": { 64 | "name": "Podłączony" 65 | } 66 | }, 67 | "button": { 68 | "flash_lights": { 69 | "name": "Sygnał Świetlny" 70 | }, 71 | "honk_horn": { 72 | "name": "Sygnał Dźwiękowy" 73 | }, 74 | "update_data": { 75 | "name": "Odśwież Dane" 76 | }, 77 | "charge_start": { 78 | "name": "Rozpocznij Ładowanie" 79 | } 80 | }, 81 | "climate": { 82 | "climate": { 83 | "name": "Klimatyzacja" 84 | } 85 | }, 86 | "device_tracker": { 87 | "location": { 88 | "name": "Lokalizacja" 89 | } 90 | }, 91 | "sensor": { 92 | "battery_level": { 93 | "name": "Poziom Naładowania Akumulatora" 94 | }, 95 | "internal_temperature": { 96 | "name": "Temperatura Wewnętrzna" 97 | }, 98 | "external_temperature": { 99 | "name": "Temperatura Zewnętrzna" 100 | }, 101 | "range_ac_on": { 102 | "name": "Zasięg (klimatyzacja włączona)" 103 | }, 104 | "range_ac_off": { 105 | "name": "Zasięg (klimatyzacja wyłączona)" 106 | }, 107 | "odometer": { 108 | "name": "Drogomierz" 109 | }, 110 | "charge_time_3kw": { 111 | "name": "Czas Ładowania (3kW)" 112 | }, 113 | "charge_time_6kw": { 114 | "name": "Czas Ładowania (6kW)" 115 | }, 116 | "last_updated": { 117 | "name": "Ostatnia Aktualizacja" 118 | }, 119 | "daily_distance": { 120 | "name": "Dzienny Dystans" 121 | }, 122 | "daily_efficiency": { 123 | "name": "Dzienna Wydajność" 124 | }, 125 | "daily_trips": { 126 | "name": "Dzienne Trasy" 127 | }, 128 | "monthly_distance": { 129 | "name": "Miesięczny Dystans" 130 | }, 131 | "monthly_efficiency": { 132 | "name": "Miesięczna Wydajność" 133 | }, 134 | "monthly_trips": { 135 | "name": "Miesięczne Trasy" 136 | } 137 | } 138 | }, 139 | "selector": { 140 | "region": { 141 | "options": { 142 | "eu": "Europa" 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "NissanConnect", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "description": "Zugangsdaten für NissanConnect eingeben", 7 | "data": { 8 | "email": "E-Mail-Adresse", 9 | "password": "Passwort", 10 | "interval": "Polling-Intervall (Minuten)", 11 | "interval_charging": "Polling-Intervall während des Ladevorgangs (Minuten)", 12 | "interval_statistics": "Update-Intervall für Statistiken (Minuten)", 13 | "interval_fetch": "Update-Intervall (Minuten)", 14 | "region": "Region", 15 | "imperial_distance": "Imperiale (amerikanische) Einheiten verwenden" 16 | }, 17 | "data_description": { 18 | "interval_charging": "Polling-Intervall: Es werden Daten direkt vom Fahrzeug angefordert (beeinflusst Batterieverbrauch).", 19 | "interval_statistics": "Update-Intervall: Es werden Daten aus der Cloud angefordert, ohne das Fahrzeug zu wecken." 20 | } 21 | } 22 | }, 23 | "error": { 24 | "auth_error": "Die Zugangsdaten sind ungültig.", 25 | "region_error": "Diese Region wird nicht unterstützt. Bitte eine unterstützte Region auswählen!" 26 | }, 27 | "abort": {} 28 | }, 29 | "options": { 30 | "step": { 31 | "init": { 32 | "description": "Zugangsdaten für NissanConnect aktualisieren", 33 | "data": { 34 | "email": "E-Mail-Adresse", 35 | "password": "Passwort", 36 | "interval": "Polling-Intervall (Minuten)", 37 | "interval_charging": "Polling-Intervall während des Ladevorgangs (Minuten)", 38 | "interval_statistics": "Update-Intervall für Statistiken (Minuten)", 39 | "interval_fetch": "Update-Intervall (Minuten)", 40 | "imperial_distance": "Imperiale (amerikanische) Einheiten verwenden" 41 | }, 42 | "data_description": { 43 | "password": "Nur bei geänderten Zugangsdaten ausfüllen", 44 | "interval_charging": "In diesem Intervall werden Daten vom Fahrzeug angefordert (beeinflusst Batterieverbrauch).", 45 | "interval_statistics": "In diesem Intervall werden neue Daten aus der Cloud angefordert, ohne das Fahrzeug zu wecken." 46 | } 47 | } 48 | }, 49 | "error": { 50 | "auth_error": "Die Zugangsdaten sind ungültig." 51 | }, 52 | "abort": {} 53 | }, 54 | "issues": {}, 55 | "entity": { 56 | "binary_sensor": { 57 | "charging": { 58 | "name": "Aufladen" 59 | }, 60 | "doors_locked": { 61 | "name": "Türverriegelung" 62 | }, 63 | "plugged": { 64 | "name": "Ladestation angeschlossen" 65 | } 66 | }, 67 | "button": { 68 | "flash_lights": { 69 | "name": "Lichthupe" 70 | }, 71 | "honk_horn": { 72 | "name": "Hupen" 73 | }, 74 | "update_data": { 75 | "name": "Daten aktualisieren" 76 | }, 77 | "charge_start": { 78 | "name": "Starten Sie den Ladevorgang" 79 | } 80 | }, 81 | "climate": { 82 | "climate": { 83 | "name": "Klimatisierung" 84 | } 85 | }, 86 | "device_tracker": { 87 | "location": { 88 | "name": "Standort" 89 | } 90 | }, 91 | "sensor": { 92 | "battery_level": { 93 | "name": "Batteriestand" 94 | }, 95 | "internal_temperature": { 96 | "name": "Innentemperatur" 97 | }, 98 | "external_temperature": { 99 | "name": "Außentemperatur" 100 | }, 101 | "range_ac_on": { 102 | "name": "Reichweite (mit Klima)" 103 | }, 104 | "range_ac_off": { 105 | "name": "Reichweite (ohne Klima)" 106 | }, 107 | "odometer": { 108 | "name": "Kilometerzähler" 109 | }, 110 | "charge_time_3kw": { 111 | "name": "Ladezeit (bei 3kW)" 112 | }, 113 | "charge_time_6kw": { 114 | "name": "Ladezeit (bei 6kW)" 115 | }, 116 | "last_updated": { 117 | "name": "zuletzt aktualisiert" 118 | }, 119 | "daily_distance": { 120 | "name": "zurückgelegte Entfernung (heute)" 121 | }, 122 | "daily_efficiency": { 123 | "name": "Effizienz (heute)" 124 | }, 125 | "daily_trips": { 126 | "name": "Anzahl Fahrten (heute)" 127 | }, 128 | "monthly_distance": { 129 | "name": "zurückgelegte Entfernung (akt. Monat)" 130 | }, 131 | "monthly_efficiency": { 132 | "name": "Effizienz (akt. Monat)" 133 | }, 134 | "monthly_trips": { 135 | "name": "Anzahl Fahrten (akt. Monat)" 136 | } 137 | } 138 | }, 139 | "selector": { 140 | "region": { 141 | "options": { 142 | "eu": "Europa" 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/climate.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for Kamereon Platform 3 | """ 4 | import logging 5 | import asyncio 6 | from time import sleep 7 | from homeassistant.components.climate import ClimateEntity 8 | from homeassistant.components.climate.const import (HVACMode, ClimateEntityFeature) 9 | from homeassistant.components.climate.const import HVACAction as HASSHVACAction 10 | from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature 11 | 12 | SUPPORT_HVAC = [HVACMode.HEAT_COOL, HVACMode.OFF] 13 | 14 | from .base import KamereonEntity 15 | from .kamereon import Feature, HVACAction 16 | from .const import DOMAIN, DATA_VEHICLES, DATA_COORDINATOR_FETCH, DATA_COORDINATOR_POLL 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | async def async_setup_entry(hass, config, async_add_entities): 22 | account_id = config.data['email'] 23 | 24 | data = hass.data[DOMAIN][account_id][DATA_VEHICLES] 25 | coordinator = hass.data[DOMAIN][account_id][DATA_COORDINATOR_FETCH] 26 | 27 | for vehicle in data: 28 | if Feature.CLIMATE_ON_OFF in data[vehicle].features: 29 | async_add_entities([KamereonClimate(coordinator, data[vehicle], hass)], update_before_add=True) 30 | 31 | 32 | class KamereonClimate(KamereonEntity, ClimateEntity): 33 | """Representation of a Kamereon Climate.""" 34 | _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON 35 | _enable_turn_on_off_backwards_compatibility = False 36 | _attr_hvac_modes = SUPPORT_HVAC 37 | _attr_temperature_unit = UnitOfTemperature.CELSIUS 38 | _attr_translation_key = "climate" 39 | _attr_min_temp = 16 40 | _attr_max_temp = 26 41 | _attr_target_temperature_step = 1 42 | _target = 20 43 | _loop_mutex = False 44 | 45 | def __init__(self, coordinator, vehicle, hass): 46 | KamereonEntity.__init__(self, coordinator, vehicle) 47 | self._hass = hass 48 | 49 | @property 50 | def hvac_mode(self): 51 | if self.vehicle.hvac_status: 52 | return HVACMode.HEAT_COOL 53 | return HVACMode.OFF 54 | 55 | @property 56 | def current_temperature(self): 57 | """Return the current temperature.""" 58 | if self.vehicle.internal_temperature is not None: 59 | return float(self.vehicle.internal_temperature) 60 | return None 61 | 62 | @property 63 | def target_temperature(self): 64 | """Return the temperature we try to reach.""" 65 | return float(self._target) 66 | 67 | @property 68 | def hvac_action(self): 69 | """Shows heating or cooling depending on temperature.""" 70 | if self.vehicle.hvac_status: 71 | if self._target < self.vehicle.internal_temperature: 72 | return HASSHVACAction.COOLING 73 | else: 74 | return HASSHVACAction.HEATING 75 | return HASSHVACAction.OFF 76 | 77 | def set_temperature(self, **kwargs): 78 | """Set new target temperatures.""" 79 | if Feature.TEMPERATURE not in self.vehicle.features: 80 | raise NotImplementedError() 81 | 82 | temperature = kwargs.get(ATTR_TEMPERATURE) 83 | if not temperature: 84 | return 85 | 86 | if self.vehicle.hvac_status: 87 | self._target = temperature 88 | self.vehicle.set_hvac_status(HVACAction.START, temperature) 89 | else: 90 | self._target = temperature 91 | 92 | async def async_set_hvac_mode(self, hvac_mode): 93 | """Set new target hvac mode.""" 94 | if Feature.CLIMATE_ON_OFF not in self.vehicle.features: 95 | raise NotImplementedError() 96 | 97 | if hvac_mode == HVACMode.OFF: 98 | await self._hass.async_add_executor_job(self.vehicle.set_hvac_status, HVACAction.STOP) 99 | self._hass.async_create_task(self._async_fetch_loop(False)) 100 | elif hvac_mode == HVACMode.HEAT_COOL: 101 | await self._hass.async_add_executor_job(self.vehicle.set_hvac_status, HVACAction.START, int(self._target)) 102 | self._hass.async_create_task(self._async_fetch_loop(True)) 103 | 104 | async def async_turn_off(self) -> None: 105 | await self.async_set_hvac_mode(HVACMode.OFF) 106 | 107 | async def async_turn_on(self) -> None: 108 | await self.async_set_hvac_mode(HVACMode.HEAT_COOL) 109 | 110 | async def _async_fetch_loop(self, target_state): 111 | """Fetch every 10 seconds for a while so we get a timely state update.""" 112 | if self._loop_mutex: 113 | return 114 | 115 | _LOGGER.debug("Beginning HVAC fetch loop") 116 | self._loop_mutex = True 117 | 118 | loop = asyncio.get_running_loop() 119 | 120 | for _ in range(10): 121 | await loop.run_in_executor(None, self.vehicle.refresh) 122 | await self.coordinator.async_refresh() 123 | 124 | # We have our update, break out 125 | if target_state == self.vehicle.hvac_status: 126 | _LOGGER.debug("Breaking out of HVAC fetch loop") 127 | break 128 | 129 | await asyncio.sleep(10) 130 | 131 | _LOGGER.debug("Ending HVAC fetch loop") 132 | self._loop_mutex = False 133 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "NissanConnect", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "description": "Configureer uw NissanConnect account.", 7 | "data": { 8 | "email": "Emailadres", 9 | "password": "Wachtwoord", 10 | "interval": "Polling interval (Minuten)", 11 | "interval_charging": "Polling interval tijdens laden (Minuten)", 12 | "interval_statistics": "Update-interval voor dagelijkse/maandelijkse statistieken (Minuten)", 13 | "interval_fetch": "Update-interval (Minuten)", 14 | "region": "Regio", 15 | "imperial_distance": "Gebruik imperiale afstandseenheden" 16 | }, 17 | "data_description": { 18 | "interval_charging": "Bij elke polling-interval wordt de auto gewekt en worden de nieuwste gegevens opgevraagd.", 19 | "interval_statistics": "Bij elk update-interval worden de nieuwste gegevens opgehaald bij Nissan, zonder de auto te wekken." 20 | } 21 | } 22 | }, 23 | "error": { 24 | "auth_error": "Ongeldige inloggegevens opgegeven.", 25 | "region_error": "Ongeldige regio opgegeven. Geef een geldige regio op." 26 | }, 27 | "abort": {} 28 | }, 29 | "options": { 30 | "step": { 31 | "init": { 32 | "description": "Update uw NissanConnect-accountgegevens.", 33 | "data": { 34 | "email": "Emailadres", 35 | "password": "Wachtwoord", 36 | "interval": "Polling interval (Minuten)", 37 | "interval_charging": "Polling interval tijdens laden (Minuten))", 38 | "interval_statistics": "Update-interval voor dagelijkse/maandelijkse statistieken (Minuten)", 39 | "interval_fetch": "Update-interval (Minuten)", 40 | "imperial_distance": "Gebruik imperiale afstandseenheden" 41 | }, 42 | "data_description": { 43 | "password": "Als u uw inloggegevens niet wijzigt, laat u het wachtwoordveld leeg.", 44 | "interval_charging": "Bij elke polling-interval wordt de auto gewekt en worden de nieuwste gegevens opgevraagd.", 45 | "interval_statistics": "Bij elk update-interval worden de nieuwste gegevens opgehaald bij Nissan, zonder de auto te wekken." 46 | } 47 | } 48 | }, 49 | "error": { 50 | "auth_error": "Ongeldige inloggegevens opgegeven." 51 | }, 52 | "abort": {} 53 | }, 54 | "issues": {}, 55 | "entity": { 56 | "binary_sensor": { 57 | "charging": { 58 | "name": "Laden" 59 | }, 60 | "doors_locked": { 61 | "name": "Deuren op slot" 62 | }, 63 | "plugged": { 64 | "name": "Ingeplugd" 65 | } 66 | }, 67 | "button": { 68 | "flash_lights": { 69 | "name": "Knipper met lampen" 70 | }, 71 | "honk_horn": { 72 | "name": "Claxon activeren" 73 | }, 74 | "update_data": { 75 | "name": "Update Data" 76 | }, 77 | "charge_start": { 78 | "name": "Laden starten" 79 | } 80 | }, 81 | "climate": { 82 | "climate": { 83 | "name": "Climate" 84 | } 85 | }, 86 | "device_tracker": { 87 | "location": { 88 | "name": "Locatie" 89 | } 90 | }, 91 | "sensor": { 92 | "battery_level": { 93 | "name": "Batterijniveau" 94 | }, 95 | "internal_temperature": { 96 | "name": "Interne temperatuur" 97 | }, 98 | "external_temperature": { 99 | "name": "Externe temperatuur" 100 | }, 101 | "range_ac_on": { 102 | "name": "Bereik (AC Aan)" 103 | }, 104 | "range_ac_off": { 105 | "name": "Bereik (AC Uit)" 106 | }, 107 | "odometer": { 108 | "name": "Kilometerstand" 109 | }, 110 | "charge_time_3kw": { 111 | "name": "Oplaadtijd (3 kW)" 112 | }, 113 | "charge_time_6kw": { 114 | "name": "Oplaadtijd (6 kW)" 115 | }, 116 | "last_updated": { 117 | "name": "Laatst bijgewerkt" 118 | }, 119 | "daily_distance": { 120 | "name": "Dagelijkse ritafstand" 121 | }, 122 | "daily_efficiency": { 123 | "name": "Dagelijkse efficiëntie" 124 | }, 125 | "daily_trips": { 126 | "name": "Dagelijkse ritten" 127 | }, 128 | "monthly_distance": { 129 | "name": "Maandelijkse ritafstand" 130 | }, 131 | "monthly_efficiency": { 132 | "name": "Maandelijkse efficiëntie" 133 | }, 134 | "monthly_trips": { 135 | "name": "Maandelijkse ritten" 136 | } 137 | } 138 | }, 139 | "selector": { 140 | "region": { 141 | "options": { 142 | "eu": "Europa" 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/translations/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "NissanConnect", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "description": "Configura tu cuenta NissanConnect.", 7 | "data": { 8 | "email": "Dirección de correo electrónico", 9 | "password": "Contraseña", 10 | "interval": "Intervalo de sondeo (minutos)", 11 | "interval_charging": "Intervalo de sondeo durante la carga (minutos)", 12 | "interval_statistics": "Intervalo de actualización de estadísticas diarias/mensuales (minutos)", 13 | "interval_fetch": "Intervalo de actualización (minutos)", 14 | "region": "Región", 15 | "imperial_distance": "Usar unidades de distancia imperiales" 16 | }, 17 | "data_description": { 18 | "interval_charging": "El automóvil se despertará y se solicitarán nuevos datos en cada intervalo de sondeo.", 19 | "interval_statistics": "En los intervalos de actualización, se obtendrán los datos más recientes de Nissan, pero el automóvil no se activará." 20 | } 21 | } 22 | }, 23 | "error": { 24 | "auth_error": "Se proporcionaron credenciales no válidas.", 25 | "region_error": "Región no válida proporcionada. " 26 | }, 27 | "abort": {} 28 | }, 29 | "options": { 30 | "step": { 31 | "init": { 32 | "description": "Actualice la información de su cuenta NissanConnect.", 33 | "data": { 34 | "email": "Dirección de correo electrónico", 35 | "password": "Contraseña", 36 | "interval": "Intervalo de sondeo (minutos)", 37 | "interval_charging": "Intervalo de sondeo durante la carga (minutos)", 38 | "interval_statistics": "Intervalo de actualización de estadísticas diarias/mensuales (minutos)", 39 | "interval_fetch": "Intervalo de actualización (minutos)", 40 | "imperial_distance": "Usar unidades de distancia imperiales" 41 | }, 42 | "data_description": { 43 | "password": "Si no va a cambiar sus credenciales, deje el campo de contraseña vacío.", 44 | "interval_charging": "El automóvil se despertará y se solicitarán nuevos datos en cada intervalo de sondeo.", 45 | "interval_statistics": "En los intervalos de actualización, se obtendrán los datos más recientes de Nissan, pero el automóvil no se activará." 46 | } 47 | } 48 | }, 49 | "error": { 50 | "auth_error": "Se proporcionaron credenciales no válidas." 51 | }, 52 | "abort": {} 53 | }, 54 | "issues": {}, 55 | "entity": { 56 | "binary_sensor": { 57 | "charging": { 58 | "name": "Cargando" 59 | }, 60 | "doors_locked": { 61 | "name": "Puertas cerradas" 62 | }, 63 | "plugged": { 64 | "name": "Conectado" 65 | } 66 | }, 67 | "button": { 68 | "flash_lights": { 69 | "name": "Luces de destello" 70 | }, 71 | "honk_horn": { 72 | "name": "Tocar la bocina" 73 | }, 74 | "update_data": { 75 | "name": "Actualizar datos" 76 | }, 77 | "charge_start": { 78 | "name": "Iniciar carga" 79 | } 80 | }, 81 | "climate": { 82 | "climate": { 83 | "name": "Clima" 84 | } 85 | }, 86 | "device_tracker": { 87 | "location": { 88 | "name": "Ubicación" 89 | } 90 | }, 91 | "sensor": { 92 | "battery_level": { 93 | "name": "Nivel de bateria" 94 | }, 95 | "internal_temperature": { 96 | "name": "Temperatura interna" 97 | }, 98 | "external_temperature": { 99 | "name": "Temperatura externa" 100 | }, 101 | "range_ac_on": { 102 | "name": "Rango (CA encendida)" 103 | }, 104 | "range_ac_off": { 105 | "name": "Rango (CA apagada)" 106 | }, 107 | "odometer": { 108 | "name": "Cuentakilómetros" 109 | }, 110 | "charge_time_3kw": { 111 | "name": "Tiempo de carga (3kW)" 112 | }, 113 | "charge_time_6kw": { 114 | "name": "Tiempo de carga (6kW)" 115 | }, 116 | "last_updated": { 117 | "name": "Última actualización" 118 | }, 119 | "daily_distance": { 120 | "name": "Distancia Diaria" 121 | }, 122 | "daily_efficiency": { 123 | "name": "Eficiencia diaria" 124 | }, 125 | "daily_trips": { 126 | "name": "Viajes diarios" 127 | }, 128 | "monthly_distance": { 129 | "name": "Distancia Mensual" 130 | }, 131 | "monthly_efficiency": { 132 | "name": "Eficiencia mensual" 133 | }, 134 | "monthly_trips": { 135 | "name": "Viajes Mensuales" 136 | } 137 | } 138 | }, 139 | "selector": { 140 | "region": { 141 | "options": { 142 | "eu": "Europa" 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/translations/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "NissanConnect", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "description": "Настройте свою учетную запись NissanConnect.", 7 | "data": { 8 | "email": "Адрес электронной почты", 9 | "password": "Пароль", 10 | "interval": "Интервал опроса (в минутах)", 11 | "interval_charging": "Интервал опроса во время зарядки (в минутах)", 12 | "interval_statistics": "Интервал обновления ежедневной/ежемесячной статистики (в минутах)", 13 | "interval_fetch": "Интервал обновления (в минутах)", 14 | "region": "Регион", 15 | "imperial_distance": "Используйте британские единицы измерения расстояния" 16 | }, 17 | "data_description": { 18 | "interval_charging": "Автомобиль будет пробуждаться и запрашиваться новые данные при каждом интервале опроса.", 19 | "interval_statistics": "Через интервалы обновления последние данные будут получены от Nissan, но автомобиль не будет разбужен." 20 | } 21 | } 22 | }, 23 | "error": { 24 | "auth_error": "Предоставлены неверные учетные данные.", 25 | "region_error": "Указан неверный регион. " 26 | }, 27 | "abort": {} 28 | }, 29 | "options": { 30 | "step": { 31 | "init": { 32 | "description": "Обновите информацию своей учетной записи NissanConnect.", 33 | "data": { 34 | "email": "Адрес электронной почты", 35 | "password": "Пароль", 36 | "interval": "Интервал опроса (в минутах)", 37 | "interval_charging": "Интервал опроса во время зарядки (в минутах)", 38 | "interval_statistics": "Интервал обновления ежедневной/ежемесячной статистики (в минутах)", 39 | "interval_fetch": "Интервал обновления (в минутах)", 40 | "imperial_distance": "Используйте британские единицы измерения расстояния" 41 | }, 42 | "data_description": { 43 | "password": "Если вы не меняете свои учетные данные, оставьте поле пароля пустым.", 44 | "interval_charging": "Автомобиль будет пробуждаться и запрашиваться новые данные при каждом интервале опроса.", 45 | "interval_statistics": "Через интервалы обновления последние данные будут получены от Nissan, но автомобиль не будет разбужен." 46 | } 47 | } 48 | }, 49 | "error": { 50 | "auth_error": "Предоставлены неверные учетные данные." 51 | }, 52 | "abort": {} 53 | }, 54 | "issues": {}, 55 | "entity": { 56 | "binary_sensor": { 57 | "charging": { 58 | "name": "Зарядка" 59 | }, 60 | "doors_locked": { 61 | "name": "Двери заперты" 62 | }, 63 | "plugged": { 64 | "name": "Подключенный" 65 | } 66 | }, 67 | "button": { 68 | "flash_lights": { 69 | "name": "Вспышки света" 70 | }, 71 | "honk_horn": { 72 | "name": "Посигналить" 73 | }, 74 | "update_data": { 75 | "name": "Обновить данные" 76 | }, 77 | "charge_start": { 78 | "name": "Начать зарядку" 79 | } 80 | }, 81 | "climate": { 82 | "climate": { 83 | "name": "Климат" 84 | } 85 | }, 86 | "device_tracker": { 87 | "location": { 88 | "name": "Расположение" 89 | } 90 | }, 91 | "sensor": { 92 | "battery_level": { 93 | "name": "Уровень заряда батареи" 94 | }, 95 | "internal_temperature": { 96 | "name": "Внутренняя температура" 97 | }, 98 | "external_temperature": { 99 | "name": "Внешняя температура" 100 | }, 101 | "range_ac_on": { 102 | "name": "Диапазон (переменный ток включен)" 103 | }, 104 | "range_ac_off": { 105 | "name": "Диапазон (переменный ток выключен)" 106 | }, 107 | "odometer": { 108 | "name": "Одометр" 109 | }, 110 | "charge_time_3kw": { 111 | "name": "Время зарядки (3 кВт)" 112 | }, 113 | "charge_time_6kw": { 114 | "name": "Время зарядки (6 кВт)" 115 | }, 116 | "last_updated": { 117 | "name": "Последнее обновление" 118 | }, 119 | "daily_distance": { 120 | "name": "Ежедневное расстояние" 121 | }, 122 | "daily_efficiency": { 123 | "name": "Ежедневная эффективность" 124 | }, 125 | "daily_trips": { 126 | "name": "Ежедневные поездки" 127 | }, 128 | "monthly_distance": { 129 | "name": "Ежемесячное расстояние" 130 | }, 131 | "monthly_efficiency": { 132 | "name": "Ежемесячная эффективность" 133 | }, 134 | "monthly_trips": { 135 | "name": "Ежемесячные поездки" 136 | } 137 | } 138 | }, 139 | "selector": { 140 | "region": { 141 | "options": { 142 | "eu": "Европа" 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/config_flow.py: -------------------------------------------------------------------------------- 1 | import voluptuous as vol 2 | from homeassistant.config_entries import (ConfigFlow, OptionsFlow) 3 | from .const import DOMAIN, CONFIG_VERSION, DEFAULT_INTERVAL_POLL, DEFAULT_INTERVAL_CHARGING, DEFAULT_INTERVAL_STATISTICS, DEFAULT_INTERVAL_FETCH, DEFAULT_REGION, REGIONS 4 | from .kamereon import NCISession 5 | import homeassistant.helpers.config_validation as cv 6 | from homeassistant.helpers import selector 7 | 8 | USER_SCHEMA = vol.Schema({ 9 | vol.Required("email"): cv.string, 10 | vol.Required("password"): cv.string, 11 | # vol.Required( 12 | # "interval", default=DEFAULT_INTERVAL_POLL 13 | # ): int, 14 | # vol.Required( 15 | # "interval_charging", default=DEFAULT_INTERVAL_CHARGING 16 | # ): int, 17 | # vol.Required( 18 | # "interval_fetch", default=DEFAULT_INTERVAL_FETCH 19 | # ): int, 20 | # vol.Required( 21 | # "interval_statistics", default=DEFAULT_INTERVAL_STATISTICS 22 | # ): int, 23 | vol.Required( 24 | "region", default=DEFAULT_REGION.lower()): selector.SelectSelector( 25 | selector.SelectSelectorConfig( 26 | options=[el.lower() for el in REGIONS], # Translation keys must be lowercase 27 | mode=selector.SelectSelectorMode.DROPDOWN, 28 | translation_key="region" 29 | ), 30 | ), 31 | vol.Required( 32 | "imperial_distance", default=False): bool 33 | }) 34 | 35 | 36 | class NissanConfigFlow(ConfigFlow, domain=DOMAIN): 37 | """Config flow.""" 38 | VERSION = CONFIG_VERSION 39 | 40 | async def async_step_user(self, info): 41 | errors = {} 42 | if info is not None: 43 | info["region"] = info["region"].upper() 44 | 45 | await self.async_set_unique_id(info["email"]) 46 | self._abort_if_unique_id_configured() 47 | 48 | # Validate credentials 49 | kamereon_session = NCISession( 50 | region=info["region"] 51 | ) 52 | 53 | try: 54 | await self.hass.async_add_executor_job(kamereon_session.login, 55 | info["email"], 56 | info["password"] 57 | ) 58 | except: 59 | errors["base"] = "auth_error" 60 | 61 | if len(errors) == 0: 62 | return self.async_create_entry( 63 | title=info["email"], 64 | data=info 65 | ) 66 | 67 | return self.async_show_form( 68 | step_id="user", data_schema=USER_SCHEMA, errors=errors 69 | ) 70 | 71 | def async_get_options_flow(entry): 72 | return NissanOptionsFlow(entry) 73 | 74 | 75 | class NissanOptionsFlow(OptionsFlow): 76 | """Options flow.""" 77 | 78 | def __init__(self, entry) -> None: 79 | self._config_entry = entry 80 | 81 | async def async_step_init(self, options): 82 | errors = {} 83 | # If form filled 84 | if options is not None: 85 | data = dict(self._config_entry.data) 86 | # Validate credentials 87 | kamereon_session = NCISession( 88 | region=data["region"] 89 | ) 90 | if "password" in options: 91 | try: 92 | await self.hass.async_add_executor_job(kamereon_session.login, 93 | self._config_entry.data.get("email"), 94 | options["password"] 95 | ) 96 | except: 97 | errors["base"] = "auth_error" 98 | 99 | # If we have no errors, update the data array 100 | if len(errors) == 0: 101 | # If password not provided, dont take the new details 102 | if not "password" in options: 103 | options.pop('email', None) 104 | options.pop('password', None) 105 | 106 | # Update data 107 | data.update(options) 108 | self.hass.config_entries.async_update_entry( 109 | self._config_entry, data=data 110 | ) 111 | 112 | # Update options 113 | return self.async_create_entry( 114 | title="", 115 | data={} 116 | ) 117 | 118 | return self.async_show_form( 119 | step_id="init", data_schema=vol.Schema({ 120 | # vol.Required("email", default=self._config_entry.data.get("email", "")): cv.string, 121 | vol.Optional("password"): cv.string, 122 | vol.Required( 123 | "interval", default=self._config_entry.data.get("interval", DEFAULT_INTERVAL_POLL) 124 | ): int, 125 | vol.Required( 126 | "interval_charging", default=self._config_entry.data.get("interval_charging", DEFAULT_INTERVAL_CHARGING) 127 | ): int, 128 | vol.Required( 129 | "interval_fetch", default=self._config_entry.data.get("interval_fetch", DEFAULT_INTERVAL_FETCH) 130 | ): int, 131 | vol.Required( 132 | "interval_statistics", default=self._config_entry.data.get("interval_statistics", DEFAULT_INTERVAL_STATISTICS) 133 | ): int, 134 | # Excluded from config flow under #61 135 | # vol.Required( 136 | # "imperial_distance", default=self._config_entry.data.get("imperial_distance", False)): bool 137 | }), errors=errors 138 | ) 139 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "NissanConnect", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "description": "Configura il tuo account NissanConnect.", 7 | "data": { 8 | "email": "Indirizzo email", 9 | "password": "Password", 10 | "interval": "Intervallo di polling (minuti)", 11 | "interval_charging": "Intervallo di polling durante la ricarica (minuti)", 12 | "interval_statistics": "Intervallo di aggiornamento per le statistiche giornaliere/mensili (minuti)", 13 | "interval_fetch": "Intervallo di aggiornamento (minuti)", 14 | "region": "Regione", 15 | "imperial_distance": "Utilizza unità di misura imperiali per la distanza" 16 | }, 17 | "data_description": { 18 | "interval_charging": "La macchina verrà svegliata e verranno richiesti nuovi dati ad ogni intervallo di aggiornamento.", 19 | "interval_statistics": "Agli intervalli di aggiornamento, i dati più recenti verranno recuperati da Nissan ma l'auto non verrà svegliata." 20 | } 21 | } 22 | }, 23 | "error": { 24 | "auth_error": "Credenziali fornite non valide.", 25 | "region_error": "Regione fornita non valida. Fornisci una regione valida." 26 | }, 27 | "abort": {} 28 | }, 29 | "options": { 30 | "step": { 31 | "init": { 32 | "description": "Aggiorna le informazioni del tuo account NissanConnect.", 33 | "data": { 34 | "email": "Indirizzo email", 35 | "password": "Password", 36 | "interval": "Intervallo di polling (minuti)", 37 | "interval_charging": "Intervallo di polling durante la ricarica (minuti)", 38 | "interval_statistics": "Intervallo di aggiornamento per le statistiche giornaliere/mensili (minuti)", 39 | "interval_fetch": "Intervallo di aggiornamento (minuti)", 40 | "imperial_distance": "Utilizza unità di misura imperiali per la distanza" 41 | }, 42 | "data_description": { 43 | "password": "Se non stai cambiando le tue credenziali, lascia vuoto il campo della password.", 44 | "interval_charging": "La macchina verrà svegliata e verranno richiesti nuovi dati ad ogni intervallo di aggiornamento.", 45 | "interval_statistics": "Agli intervalli di aggiornamento, i dati più recenti verranno recuperati da Nissan ma l'auto non verrà svegliata." 46 | } 47 | } 48 | }, 49 | "error": { 50 | "auth_error": "Credenziali fornite non valide." 51 | }, 52 | "abort": {} 53 | }, 54 | "issues": {}, 55 | "entity": { 56 | "binary_sensor": { 57 | "charging": { 58 | "name": "In carica" 59 | }, 60 | "doors_locked": { 61 | "name": "Porte chiuse a chiave" 62 | }, 63 | "plugged": { 64 | "name": "Collegata" 65 | } 66 | }, 67 | "button": { 68 | "flash_lights": { 69 | "name": "Lampeggia luci" 70 | }, 71 | "honk_horn": { 72 | "name": "Suona il clacson" 73 | }, 74 | "update_data": { 75 | "name": "Aggiorna dati" 76 | }, 77 | "charge_start": { 78 | "name": "Avvia carica" 79 | } 80 | }, 81 | "climate": { 82 | "climate": { 83 | "name": "Climatizzatore" 84 | } 85 | }, 86 | "device_tracker": { 87 | "location": { 88 | "name": "Posizione" 89 | } 90 | }, 91 | "sensor": { 92 | "battery_level": { 93 | "name": "Livello della batteria" 94 | }, 95 | "internal_temperature": { 96 | "name": "Temperatura interna" 97 | }, 98 | "external_temperature": { 99 | "name": "Temperatura esterna" 100 | }, 101 | "range_ac_on": { 102 | "name": "Autonomia (AC attivato)" 103 | }, 104 | "range_ac_off": { 105 | "name": "Autonomia (AC disattivato)" 106 | }, 107 | "odometer": { 108 | "name": "Conta chilometri" 109 | }, 110 | "charge_time_3kw": { 111 | "name": "Tempo di ricarica (3 kW)" 112 | }, 113 | "charge_time_6kw": { 114 | "name": "Tempo di ricarica (6 kW)" 115 | }, 116 | "last_updated": { 117 | "name": "Ultimo aggiornamento" 118 | }, 119 | "daily_distance": { 120 | "name": "Distanza giornaliera" 121 | }, 122 | "daily_efficiency": { 123 | "name": "Efficienza giornaliera" 124 | }, 125 | "daily_trips": { 126 | "name": "Viaggi giornalieri" 127 | }, 128 | "monthly_distance": { 129 | "name": "Distanza mensile" 130 | }, 131 | "monthly_efficiency": { 132 | "name": "Efficienza mensile" 133 | }, 134 | "monthly_trips": { 135 | "name": "Viaggi mensili" 136 | } 137 | } 138 | }, 139 | "selector": { 140 | "region": { 141 | "options": { 142 | "eu": "Europa" 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "NissanConnect", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "description": "Configurez votre compte NissanConnect.", 7 | "data": { 8 | "email": "Adresse e-mail", 9 | "password": "Mot de passe", 10 | "interval": "Intervalle de sondage (minutes)", 11 | "interval_charging": "Intervalle de sondage pendant la charge (minutes)", 12 | "interval_statistics": "Intervalle de mise à jour des statistiques quotidiennes/mensuelles (minutes)", 13 | "interval_fetch": "Intervalle de mise à jour (minutes)", 14 | "region": "Région", 15 | "imperial_distance": "Utiliser les unités de distance impériales" 16 | }, 17 | "data_description": { 18 | "interval_charging": "La voiture sera réveillée et de nouvelles données seront demandées à chaque intervalle de mise à jour.", 19 | "interval_statistics": "Aux intervalles de mise à jour, les dernières données seront récupérées auprès de Nissan mais la voiture ne sera pas réveillée." 20 | } 21 | } 22 | }, 23 | "error": { 24 | "auth_error": "Informations d'identification invalides.", 25 | "region_error": "Région non valide fournie. Veuillez fournir une région valide." 26 | }, 27 | "abort": {} 28 | }, 29 | "options": { 30 | "step": { 31 | "init": { 32 | "description": "Mettez à jour les informations de votre compte NissanConnect.", 33 | "data": { 34 | "email": "Adresse e-mail", 35 | "password": "Mot de passe", 36 | "interval": "Intervalle de sondage (minutes)", 37 | "interval_charging": "Intervalle de sondage pendant la charge (minutes)", 38 | "interval_statistics": "Intervalle de mise à jour des statistiques quotidiennes/mensuelles (minutes)", 39 | "interval_fetch": "Intervalle de mise à jour (minutes)", 40 | "imperial_distance": "Utiliser les unités de distance impériales" 41 | }, 42 | "data_description": { 43 | "password": "Si vous ne changez pas vos informations d'identification, laissez le champ du mot de passe vide.", 44 | "interval_charging": "La voiture sera réveillée et de nouvelles données seront demandées à chaque intervalle de mise à jour.", 45 | "interval_statistics": "Aux intervalles de mise à jour, les dernières données seront récupérées auprès de Nissan mais la voiture ne sera pas réveillée." 46 | } 47 | } 48 | }, 49 | "error": { 50 | "auth_error": "Informations d'identification invalides." 51 | }, 52 | "abort": {} 53 | }, 54 | "issues": {}, 55 | "entity": { 56 | "binary_sensor": { 57 | "charging": { 58 | "name": "En charge" 59 | }, 60 | "doors_locked": { 61 | "name": "Portes verrouillées" 62 | }, 63 | "plugged": { 64 | "name": "Branché" 65 | } 66 | }, 67 | "button": { 68 | "flash_lights": { 69 | "name": "Clignoter les feux" 70 | }, 71 | "honk_horn": { 72 | "name": "Biper le klaxon" 73 | }, 74 | "update_data": { 75 | "name": "Mettre à jour les données" 76 | }, 77 | "charge_start": { 78 | "name": "Démarrer la recharge" 79 | } 80 | }, 81 | "climate": { 82 | "climate": { 83 | "name": "Climatisation" 84 | } 85 | }, 86 | "device_tracker": { 87 | "location": { 88 | "name": "Emplacement" 89 | } 90 | }, 91 | "sensor": { 92 | "battery_level": { 93 | "name": "Niveau de batterie" 94 | }, 95 | "internal_temperature": { 96 | "name": "Température interne" 97 | }, 98 | "external_temperature": { 99 | "name": "Température externe" 100 | }, 101 | "range_ac_on": { 102 | "name": "Autonomie (AC activé)" 103 | }, 104 | "range_ac_off": { 105 | "name": "Autonomie (AC désactivé)" 106 | }, 107 | "odometer": { 108 | "name": "Compteur kilométrique" 109 | }, 110 | "charge_time_3kw": { 111 | "name": "Temps de charge (3 kW)" 112 | }, 113 | "charge_time_6kw": { 114 | "name": "Temps de charge (6 kW)" 115 | }, 116 | "last_updated": { 117 | "name": "Dernière mise à jour" 118 | }, 119 | "daily_distance": { 120 | "name": "Distance quotidienne" 121 | }, 122 | "daily_efficiency": { 123 | "name": "Efficacité quotidienne" 124 | }, 125 | "daily_trips": { 126 | "name": "Trajets quotidiens" 127 | }, 128 | "monthly_distance": { 129 | "name": "Distance mensuelle" 130 | }, 131 | "monthly_efficiency": { 132 | "name": "Efficacité mensuelle" 133 | }, 134 | "monthly_trips": { 135 | "name": "Trajets mensuels" 136 | } 137 | } 138 | }, 139 | "selector": { 140 | "region": { 141 | "options": { 142 | "eu": "Europe" 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/coordinator.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from datetime import timedelta 4 | from time import time 5 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 6 | from .const import DOMAIN, DATA_VEHICLES, DEFAULT_INTERVAL_POLL, DEFAULT_INTERVAL_CHARGING, DEFAULT_INTERVAL_STATISTICS, DEFAULT_INTERVAL_FETCH, DATA_COORDINATOR_FETCH, DATA_COORDINATOR_POLL 7 | from .kamereon import Feature, PluggedStatus, ChargingStatus, Period 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | class KamereonFetchCoordinator(DataUpdateCoordinator): 13 | def __init__(self, hass, config): 14 | """Coordinator to fetch the latest states.""" 15 | super().__init__( 16 | hass, 17 | _LOGGER, 18 | name="Update Coordinator", 19 | update_interval=timedelta(minutes=config.get("interval_fetch", DEFAULT_INTERVAL_FETCH)), 20 | ) 21 | self._hass = hass 22 | self._account_id = config['email'] 23 | self._vehicles = hass.data[DOMAIN][self._account_id][DATA_VEHICLES] 24 | 25 | async def _async_update_data(self): 26 | """Fetch data from API.""" 27 | try: 28 | for vehicle in self._vehicles: 29 | await self._hass.async_add_executor_job(self._vehicles[vehicle].fetch_all) 30 | except BaseException: 31 | _LOGGER.warning("Error communicating with API") 32 | return False 33 | 34 | # Set interval for polling (the other coordinator) 35 | self._hass.data[DOMAIN][self._account_id][DATA_COORDINATOR_POLL].set_next_interval() 36 | 37 | return True 38 | 39 | 40 | class KamereonPollCoordinator(DataUpdateCoordinator): 41 | def __init__(self, hass, config): 42 | """Coordinator to poll the car for updates.""" 43 | super().__init__( 44 | hass, 45 | _LOGGER, 46 | name="Poll Coordinator", 47 | # This interval is overwritten by _set_next_interval in the first run 48 | update_interval=timedelta(minutes=15), 49 | ) 50 | self._hass = hass 51 | self._account_id = config['email'] 52 | self._vehicles = hass.data[DOMAIN][self._account_id][DATA_VEHICLES] 53 | self._config = config 54 | 55 | self._pluggednotcharging = {key: 0 for key in self._vehicles} 56 | self._intervals = {key: 0 for key in self._vehicles} 57 | self._last_updated = {key: 0 for key in self._vehicles} 58 | self._force_update = {key: False for key in self._vehicles} 59 | 60 | def set_next_interval(self): 61 | """Calculate the next update interval.""" 62 | interval = self._config.get("interval", DEFAULT_INTERVAL_POLL) 63 | interval_charging = self._config.get("interval_charging", DEFAULT_INTERVAL_CHARGING) 64 | 65 | # Get the shortest interval from all vehicles 66 | for vehicle in self._vehicles: 67 | # Initially set interval to default 68 | new_interval = interval 69 | 70 | # EV, decide which time to use based on whether we are plugged in or not 71 | if Feature.BATTERY_STATUS in self._vehicles[vehicle].features and self._vehicles[vehicle].plugged_in == PluggedStatus.PLUGGED: 72 | # If we are plugged in but not charging, increment a counter 73 | if self._vehicles[vehicle].charging != ChargingStatus.CHARGING: 74 | self._pluggednotcharging[vehicle] += 1 75 | else: # If we are plugged in and charging, reset counter 76 | self._pluggednotcharging[vehicle] = 0 77 | 78 | # If we haven't hit the counter limit, use the shorter interval 79 | if self._pluggednotcharging[vehicle] < 5: 80 | new_interval = interval_charging 81 | 82 | # Update every minute if HVAC on 83 | if self._vehicles[vehicle].hvac_status: 84 | new_interval = 1 85 | 86 | # If the interval has changed, force next update 87 | if new_interval != self._intervals[vehicle]: 88 | _LOGGER.debug(f"Changing #{vehicle[-3:]} update interval to {new_interval} minutes") 89 | self._force_update[vehicle] = True 90 | 91 | self._intervals[vehicle] = new_interval 92 | 93 | # Set the coordinator to update at the shortest interval 94 | shortest_interval = min(self._intervals.values()) 95 | 96 | if shortest_interval != (self.update_interval.seconds / 60): 97 | _LOGGER.debug(f"Changing coordinator update interval to {shortest_interval} minutes") 98 | self.update_interval = timedelta(minutes=shortest_interval) 99 | self._async_unsub_refresh() 100 | if self._listeners: 101 | self._schedule_refresh() 102 | 103 | async def _async_update_data(self): 104 | """Fetch data from API.""" 105 | try: 106 | for vehicle in self._vehicles: 107 | time_since_updated = round((time() - self._last_updated[vehicle]) / 60) 108 | if not self._intervals[vehicle] == 0 and (self._force_update[vehicle] or time_since_updated >= self._intervals[vehicle]): 109 | _LOGGER.debug("Polling #%s as %d mins have elapsed (interval %d)", vehicle[-3:], time_since_updated, self._intervals[vehicle]) 110 | self._last_updated[vehicle] = int(time()) 111 | self._force_update[vehicle] = False 112 | await self._hass.async_add_executor_job(self._vehicles[vehicle].refresh) 113 | else: 114 | _LOGGER.debug("NOT polling #%s. %d mins have elapsed (interval %d)", vehicle[-3:], time_since_updated, self._intervals[vehicle]) 115 | except BaseException: 116 | _LOGGER.warning("Error communicating with API") 117 | return False 118 | 119 | self._hass.async_create_task(self._hass.data[DOMAIN][self._account_id][DATA_COORDINATOR_FETCH].async_refresh()) 120 | return True 121 | 122 | 123 | class StatisticsCoordinator(DataUpdateCoordinator): 124 | def __init__(self, hass, config): 125 | """Initialise coordinator.""" 126 | super().__init__( 127 | hass, 128 | _LOGGER, 129 | name="Statistics Coordinator", 130 | update_interval=timedelta(minutes=config.get("interval_statistics", DEFAULT_INTERVAL_STATISTICS)), 131 | ) 132 | self._hass = hass 133 | self._account_id = config['email'] 134 | self._vehicles = hass.data[DOMAIN][self._account_id][DATA_VEHICLES] 135 | 136 | async def _async_update_data(self): 137 | """Fetch data from API.""" 138 | output = {} 139 | try: 140 | for vehicle in self._vehicles: 141 | if not Feature.DRIVING_JOURNEY_HISTORY in self._vehicles[vehicle].features: 142 | continue 143 | 144 | output[vehicle] = { 145 | 'daily': await self._hass.async_add_executor_job(self._vehicles[vehicle].fetch_trip_histories, Period.DAILY), 146 | 'monthly': await self._hass.async_add_executor_job(self._vehicles[vehicle].fetch_trip_histories, Period.MONTHLY) 147 | } 148 | except BaseException: 149 | _LOGGER.warning("Error communicating with statistics API") 150 | 151 | return output 152 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import math 3 | 4 | from homeassistant.components.sensor import ( 5 | SensorDeviceClass, 6 | SensorEntity, 7 | UnitOfTemperature 8 | ) 9 | from homeassistant.core import callback 10 | from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfTime 11 | from homeassistant.components.sensor import SensorStateClass 12 | from .base import KamereonEntity 13 | from .kamereon import ChargingSpeed, Feature 14 | from .const import DOMAIN, DATA_VEHICLES, DATA_COORDINATOR_FETCH, DATA_COORDINATOR_STATISTICS 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | async def async_setup_entry(hass, config, async_add_entities): 19 | """Set up the Kamereon sensors.""" 20 | account_id = config.data['email'] 21 | 22 | data = hass.data[DOMAIN][account_id][DATA_VEHICLES] 23 | coordinator = hass.data[DOMAIN][account_id][DATA_COORDINATOR_FETCH] 24 | coordinator_stats = hass.data[DOMAIN][account_id][DATA_COORDINATOR_STATISTICS] 25 | 26 | entities = [] 27 | 28 | imperial_distance = config.data.get("imperial_distance", False) 29 | 30 | for vehicle in data: 31 | if Feature.BATTERY_STATUS in data[vehicle].features or data[vehicle].range_hvac_on is not None: 32 | entities += [RangeSensor(coordinator, data[vehicle], True, imperial_distance), 33 | TimestampSensor(coordinator, data[vehicle], 'battery_status_last_updated', 'last_updated', 'mdi:clock-time-eleven-outline')] 34 | if Feature.BATTERY_STATUS in data[vehicle].features: 35 | entities.append(BatteryLevelSensor(coordinator, data[vehicle])) 36 | if data[vehicle].charge_time_required_to_full[ChargingSpeed.NORMAL] is not None: 37 | entities += [ChargeTimeRequiredSensor(coordinator, data[vehicle], ChargingSpeed.NORMAL), 38 | ChargeTimeRequiredSensor(coordinator, data[vehicle], ChargingSpeed.FAST)] 39 | if data[vehicle].charge_time_required_to_full[ChargingSpeed.ADAPTIVE] is not None: 40 | entities.append(ChargeTimeRequiredSensor(coordinator, data[vehicle], ChargingSpeed.ADAPTIVE)) 41 | if data[vehicle].range_hvac_off is not None: 42 | entities.append(RangeSensor(coordinator, data[vehicle], False, imperial_distance)) 43 | if data[vehicle].internal_temperature is not None: 44 | entities.append(InternalTemperatureSensor(coordinator, data[vehicle])) 45 | if data[vehicle].external_temperature is not None: 46 | entities.append(ExternalTemperatureSensor(coordinator, data[vehicle])) 47 | if Feature.DRIVING_JOURNEY_HISTORY in data[vehicle].features: 48 | entities += [ 49 | StatisticSensor(coordinator_stats, data[vehicle], 'daily', lambda x: x.total_distance, 'daily_distance', 'mdi:map-marker-distance', SensorDeviceClass.DISTANCE, UnitOfLength.KILOMETERS, 0, imperial_distance), 50 | StatisticSensor(coordinator_stats, data[vehicle], 'daily', lambda x: x.trip_count, 'daily_trips', 'mdi:hiking', None, None, 0), 51 | StatisticSensor(coordinator_stats, data[vehicle], 'monthly', lambda x: x.total_distance, 'monthly_distance', 'mdi:map-marker-distance', SensorDeviceClass.DISTANCE, UnitOfLength.KILOMETERS, 0, imperial_distance), 52 | StatisticSensor(coordinator_stats, data[vehicle], 'monthly', lambda x: x.trip_count, 'monthly_trips', 'mdi:hiking', None, None, 0), 53 | ] 54 | if Feature.BATTERY_STATUS in data[vehicle].features: 55 | entities += [ 56 | StatisticSensor(coordinator_stats, data[vehicle], 'daily', lambda x: x.total_distance / x.consumed_electricity, 'daily_efficiency', 'mdi:ev-station', SensorDeviceClass.DISTANCE, UnitOfLength.KILOMETERS, 2, imperial_distance), 57 | StatisticSensor(coordinator_stats, data[vehicle], 'monthly', lambda x: x.total_distance / x.consumed_electricity, 'monthly_efficiency', 'mdi:ev-station', SensorDeviceClass.DISTANCE, UnitOfLength.KILOMETERS, 2, imperial_distance), 58 | ] 59 | 60 | entities.append(OdometerSensor(coordinator, data[vehicle], imperial_distance)) 61 | 62 | async_add_entities(entities, update_before_add=True) 63 | 64 | 65 | class BatteryLevelSensor(KamereonEntity, SensorEntity): 66 | _attr_translation_key = "battery_level" 67 | _attr_device_class = SensorDeviceClass.BATTERY 68 | _attr_native_unit_of_measurement = PERCENTAGE 69 | _attr_state_class = SensorStateClass.MEASUREMENT 70 | 71 | def __init__(self, coordinator, vehicle): 72 | KamereonEntity.__init__(self, coordinator, vehicle) 73 | 74 | @property 75 | def state(self): 76 | """Return the state.""" 77 | return self.vehicle.battery_level 78 | 79 | @property 80 | def icon(self): 81 | """Icon of the sensor. Round up to the nearest 10% icon.""" 82 | nearest = math.ceil((self.state or 0) / 10.0) * 10 83 | if nearest == 0: 84 | return "mdi:battery-outline" 85 | elif nearest == 100: 86 | return "mdi:battery" 87 | else: 88 | return "mdi:battery-" + str(nearest) 89 | 90 | @property 91 | def device_state_attributes(self): 92 | """Return device specific state attributes.""" 93 | a = KamereonEntity.device_state_attributes.fget(self) 94 | a.update({ 95 | 'battery_capacity': self.vehicle.battery_capacity, 96 | 'battery_level': self.vehicle.battery_level, 97 | }) 98 | 99 | 100 | class InternalTemperatureSensor(KamereonEntity, SensorEntity): 101 | _attr_translation_key = "internal_temperature" 102 | _attr_device_class = SensorDeviceClass.TEMPERATURE 103 | _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS 104 | 105 | @property 106 | def native_value(self): 107 | """Return the state.""" 108 | return self.vehicle.internal_temperature 109 | 110 | @property 111 | def icon(self): 112 | """Icon of the sensor.""" 113 | return "mdi:thermometer" 114 | 115 | @property 116 | def device_state_attributes(self): 117 | """Return device specific state attributes.""" 118 | a = KamereonEntity.device_state_attributes.fget(self) 119 | a.update({ 120 | 'battery_capacity': self.vehicle.battery_capacity, 121 | 'battery_bar_level': self.vehicle.battery_bar_level, 122 | }) 123 | return a 124 | 125 | 126 | class ExternalTemperatureSensor(KamereonEntity, SensorEntity): 127 | _attr_translation_key = "external_temperature" 128 | _attr_device_class = SensorDeviceClass.TEMPERATURE 129 | _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS 130 | 131 | @property 132 | def native_value(self): 133 | """Return the state.""" 134 | return self.vehicle.external_temperature 135 | 136 | @property 137 | def icon(self): 138 | """Icon of the sensor.""" 139 | return "mdi:thermometer" 140 | 141 | @property 142 | def device_state_attributes(self): 143 | """Return device specific state attributes.""" 144 | a = KamereonEntity.device_state_attributes.fget(self) 145 | a.update({ 146 | 'battery_capacity': self.vehicle.battery_capacity, 147 | 'battery_bar_level': self.vehicle.battery_bar_level, 148 | }) 149 | return a 150 | 151 | 152 | class RangeSensor(KamereonEntity, SensorEntity): 153 | _attr_device_class = SensorDeviceClass.DISTANCE 154 | _attr_native_unit_of_measurement = UnitOfLength.KILOMETERS 155 | 156 | def __init__(self, coordinator, vehicle, hvac, imperial_distance): 157 | if imperial_distance: 158 | self._attr_suggested_unit_of_measurement = UnitOfLength.MILES 159 | 160 | self._attr_translation_key = "range_ac_on" if hvac else "range_ac_off" 161 | KamereonEntity.__init__(self, coordinator, vehicle) 162 | self.hvac = hvac 163 | 164 | @property 165 | def native_value(self): 166 | """Return the state.""" 167 | val = getattr(self.vehicle, 'range_hvac_{}'.format( 168 | 'on' if self.hvac else 'off')) 169 | return val 170 | 171 | @property 172 | def icon(self): 173 | """Icon of the sensor.""" 174 | return "mdi:map-marker-distance" 175 | 176 | 177 | class OdometerSensor(KamereonEntity, SensorEntity): 178 | _attr_translation_key = "odometer" 179 | _attr_device_class = SensorDeviceClass.DISTANCE 180 | _attr_native_unit_of_measurement = UnitOfLength.KILOMETERS 181 | _attr_state_class = SensorStateClass.TOTAL_INCREASING 182 | 183 | def __init__(self, coordinator, vehicle, imperial_distance): 184 | if imperial_distance: 185 | self._attr_suggested_unit_of_measurement = UnitOfLength.MILES 186 | 187 | self._state = None 188 | 189 | KamereonEntity.__init__(self, coordinator, vehicle) 190 | 191 | @callback 192 | def _handle_coordinator_update(self) -> None: 193 | new_state = getattr(self.vehicle, "total_mileage") 194 | 195 | # This sometimes goes backwards? So only accept a positive odometer delta 196 | if new_state is not None and new_state > (self._state or 0): 197 | self._state = new_state 198 | self.async_write_ha_state() 199 | 200 | @property 201 | def native_value(self): 202 | """Return the state.""" 203 | return self._state 204 | 205 | @property 206 | def icon(self): 207 | """Icon of the sensor.""" 208 | return "mdi:counter" 209 | 210 | 211 | class StatisticSensor(KamereonEntity, SensorEntity): 212 | def __init__(self, coordinator, vehicle, key, func, translation_key, icon, device_class, unit, precision, imperial_distance=False): 213 | self._attr_device_class = device_class 214 | self._attr_native_unit_of_measurement = unit 215 | self._attr_suggested_display_precision = precision 216 | if imperial_distance: 217 | self._attr_suggested_unit_of_measurement = UnitOfLength.MILES 218 | self._attr_translation_key = translation_key 219 | self._icon = icon 220 | self._key = key 221 | self._lambda = func 222 | self._state = None 223 | self._attributes = {} 224 | KamereonEntity.__init__(self, coordinator, vehicle) 225 | 226 | @property 227 | def native_value(self): 228 | """Return the state.""" 229 | return self._state 230 | 231 | @property 232 | def extra_state_attributes(self): 233 | """Attributes of the sensor.""" 234 | return self._attributes 235 | 236 | @callback 237 | def _handle_coordinator_update(self) -> None: 238 | if self.coordinator.data is None or self.vehicle.vin not in self.coordinator.data: 239 | return 240 | 241 | summary = self.coordinator.data[self.vehicle.vin][self._key] 242 | 243 | # No summaries yet, return 0 244 | if len(summary) == 0: 245 | self._state = 0 246 | self.async_write_ha_state() 247 | return 248 | 249 | # For statistic sensors, default to 0 on error 250 | try: 251 | self._state = self._lambda(summary[0]) 252 | except: 253 | self._state = 0 254 | 255 | self.async_write_ha_state() 256 | 257 | @property 258 | def icon(self): 259 | """Icon of the sensor.""" 260 | return self._icon 261 | 262 | 263 | class ChargeTimeRequiredSensor(KamereonEntity, SensorEntity): 264 | _attr_translation_key = "charge_time" 265 | _attr_device_class = SensorDeviceClass.DURATION 266 | _attr_native_unit_of_measurement = UnitOfTime.MINUTES 267 | _attr_suggested_unit_of_measurement = UnitOfTime.HOURS 268 | 269 | CHARGING_SPEED_NAME = { 270 | ChargingSpeed.FASTEST: '50kw', 271 | ChargingSpeed.FAST: '6kw', 272 | ChargingSpeed.NORMAL: '3kw', 273 | ChargingSpeed.SLOW: '1kw', 274 | ChargingSpeed.ADAPTIVE: 'adaptive' 275 | } 276 | 277 | def __init__(self, coordinator, vehicle, charging_speed): 278 | self.charging_speed = charging_speed 279 | self._attr_translation_key = "charge_time_" + self.CHARGING_SPEED_NAME[charging_speed] 280 | KamereonEntity.__init__(self, coordinator, vehicle) 281 | 282 | @property 283 | def native_value(self): 284 | """Return the state.""" 285 | return self.vehicle.charge_time_required_to_full[self.charging_speed] 286 | 287 | @property 288 | def icon(self): 289 | """Icon of the sensor.""" 290 | return "mdi:battery-clock" 291 | 292 | 293 | class TimestampSensor(KamereonEntity, SensorEntity): 294 | _attr_device_class = SensorDeviceClass.TIMESTAMP 295 | 296 | def __init__(self, coordinator, vehicle, attribute, translation_key, icon): 297 | self._attr_translation_key = translation_key 298 | self._icon = icon 299 | KamereonEntity.__init__(self, coordinator, vehicle) 300 | self.attribute = attribute 301 | 302 | @property 303 | def icon(self): 304 | """Icon of the sensor.""" 305 | return self._icon 306 | 307 | @property 308 | def state(self): 309 | """Return the state.""" 310 | val = getattr(self.vehicle, self.attribute) 311 | if val is None: 312 | return None 313 | return val.isoformat() 314 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/kamereon/kamereon_const.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | API_VERSION = 'protocol=1.0,resource=2.1' 4 | SRP_KEY = 'D5AF0E14718E662D12DBB4FE42304DF5A8E48359E22261138B40AA16CC85C76A11B43200A1EECB3C9546A262D1FBD51ACE6FCDE558C00665BBF93FF86B9F8F76AA7A53CA74F5B4DFF9A4B847295E7D82450A2078B5A28814A7A07F8BBDD34F8EEB42B0E70499087A242AA2C5BA9513C8F9D35A81B33A121EEF0A71F3F9071CCD' 5 | 6 | SETTINGS_MAP = { 7 | 'nissan': { 8 | 'EU': { 9 | 'client_id': 'a-ncb-nc-android-prod', 10 | 'client_secret': '6GKIax7fGT5yPHuNmWNVOc4q5POBw1WRSW39ubRA8WPBmQ7MOxhm75EsmKMKENem', 11 | 'scope': 'openid profile vehicles', 12 | 'auth_base_url': 'https://prod.eu2.auth.kamereon.org/kauth/', 13 | 'realm': 'a-ncb-prod', 14 | 'redirect_uri': 'org.kamereon.service.nci:/oauth2redirect', 15 | 'car_adapter_base_url': 'https://alliance-platform-caradapter-prod.apps.eu2.kamereon.io/car-adapter/', 16 | 'notifications_base_url': 'https://alliance-platform-notifications-prod.apps.eu2.kamereon.io/notifications/', 17 | 'user_adapter_base_url': 'https://alliance-platform-usersadapter-prod.apps.eu2.kamereon.io/user-adapter/', 18 | 'user_base_url': 'https://nci-bff-web-prod.apps.eu2.kamereon.io/bff-web/' 19 | } 20 | } 21 | } 22 | 23 | 24 | USERS = 'users' 25 | VEHICLES = 'vehicles' 26 | CATEGORIES = 'categories' 27 | NOTIFICATION_RULES = 'notification_rules' 28 | NOTIFICATION_TYPES = 'notification_types' 29 | NOTIFICATION_CATEGORIES = 'notification_categories' 30 | 31 | class HVACAction(enum.Enum): 32 | # Start or schedule start 33 | START = 'start' 34 | # Stop active HVAC 35 | STOP = 'stop' 36 | # Cancel scheduled HVAC 37 | CANCEL = 'cancel' 38 | 39 | 40 | class LockStatus(enum.Enum): 41 | CLOSED = 'closed' 42 | LOCKED = 'locked' 43 | OPEN = 'open' 44 | UNLOCKED = 'unlocked' 45 | 46 | 47 | class Door(enum.Enum): 48 | HATCH = 'hatch' 49 | FRONT_LEFT = 'front_left' 50 | FRONT_RIGHT = 'front_right' 51 | REAR_LEFT = 'rear_left' 52 | REAR_RIGHT = 'rear_right' 53 | 54 | 55 | class LockableDoorGroup(enum.Enum): 56 | DOORS_AND_HATCH = 'doors_hatch' 57 | DRIVERS_DOOR = 'driver_s_door' 58 | HATCH = 'hatch' 59 | 60 | 61 | class ChargingSpeed(enum.Enum): 62 | NONE = None 63 | SLOW = 1 64 | NORMAL = 2 65 | FAST = 3 66 | FASTEST = 4 67 | ADAPTIVE = 5 68 | 69 | 70 | class ChargingStatus(enum.Enum): 71 | ERROR = -1 72 | NOT_CHARGING = 0 73 | CHARGING = 1 74 | 75 | 76 | class PluggedStatus(enum.Enum): 77 | ERROR = -1 78 | NOT_PLUGGED = 0 79 | PLUGGED = 1 80 | 81 | 82 | class Period(enum.Enum): 83 | DAILY = 0 84 | MONTHLY = 1 85 | YEARLY = 2 86 | 87 | 88 | class Feature(enum.Enum): 89 | BREAKDOWN_ASSISTANCE_CALL = '1' 90 | SVT_WITH_VEHICLE_BLOCKAGE = '10' 91 | MAINTENANCE_ALERT = '101' 92 | VEHICLE_SOFTWARE_UPDATES = '107' 93 | MY_CAR_FINDER = '12' 94 | MIL_ON_NOTIFICATION = '15' 95 | VEHICLE_HEALTH_REPORT = '18' 96 | ADVANCED_CAN = '201' 97 | VEHICLE_STATUS_CHECK = '202' 98 | LOCK_STATUS_CHECK = '2021' 99 | NAVIGATION_FACTORY_RESET = '208' 100 | MESSAGES_TO_THE_VEHICLE = '21' 101 | VEHICLE_DATA = '2121' 102 | VEHICLE_DATA_2 = '2122' 103 | VEHICLE_WIFI = '213' 104 | ADVANCED_VEHICLE_DIAGNOSTICS = '215' 105 | NAVIGATION_MAP_UPDATES = '217' 106 | VEHICLE_SETTINGS_TRANSFER = '221' 107 | LAST_MILE_NAVIGATION = '227' 108 | GOOGLE_STREET_VIEW = '229' 109 | GOOGLE_SATELITE_VIEW = '230' 110 | DYNAMIC_EV_ICE_RANGE = '232' 111 | ECO_ROUTE_CALCULATION = '233' 112 | CO_PILOT = '234' 113 | DRIVING_JOURNEY_HISTORY = '235' 114 | NISSAN_RENAULT_BROADCASTS = '241' 115 | ONLINE_PARKING_INFO = '243' 116 | ONLINE_RESTAURANT_INFO = '244' 117 | ONLINE_SPEED_RESTRICTION_INFO = '245' 118 | WEATHER_INFO = '246' 119 | VEHICLE_ACCESS_TO_EMAIL = '248' 120 | VEHICLE_ACCESS_TO_MUSIC = '249' 121 | VEHICLE_ACCESS_TO_CONTACTS = '262' 122 | APP_DOOR_LOCKING = '27' 123 | GLONASS = '276' 124 | ZONE_ALERT = '281' 125 | SPEEDING_ALERT = '282' 126 | SERVICE_SUBSCRIPTION = '284' 127 | PAY_HOW_YOU_DRIVE = '286' 128 | CHARGING_SPOT_INFO = '288' 129 | FLEET_ASSET_INFORMATION = '29' 130 | CHARGING_SPOT_INFO_COLLECTION = '292' 131 | CHARGING_START = '299' 132 | CHARGING_STOP = '303' 133 | INTERIOR_TEMP_SETTINGS = '307' 134 | CLIMATE_ON_OFF_NOTIFICATION = '311' 135 | CHARGING_SPOT_SEARCH = '312' 136 | PLUG_IN_REMINDER = '314' 137 | CHARGING_STOP_NOTIFICATION = '317' 138 | BATTERY_STATUS = '319' 139 | BATTERY_HEATING_NOTIFICATION = '320' 140 | VEHICLE_STATE_OF_CHARGE_PERCENT = '322' 141 | BATTERY_STATE_OF_HEALTH_PERCENT = '323' 142 | PAY_AS_YOU_DRIVE = '34' 143 | DRIVING_ANALYSIS = '340' 144 | CO2_GAS_SAVINGS = '341' 145 | ELECTRICITY_FEE_CALCULATION = '342' 146 | CHARGING_CONSUMPTION_HISTORY = '344' 147 | BATTERY_MONITORING = '345' 148 | BATTERY_DATA = '347' 149 | APP_BASED_NAVIGATION = '35' 150 | CHARGING_SPOT_UPDATES = '354' 151 | RECHARGEABLE_AREA = '358' 152 | NO_CHARGING_SPOT_INFO = '359' 153 | EV_RANGE = '360' 154 | CLIMATE_ON_OFF = '366' 155 | ONLINE_FUEL_STATION_INFO = '367' 156 | DESTINATION_SEND_TO_CAR = '37' 157 | ECALL = '4' 158 | GOOGLE_PLACES_SEARCH = '40' 159 | PREMIUM_TRAFFIC = '43' 160 | AUTO_COLLISION_NOTIFICATION_ACN = '6' 161 | THEFT_BURGLAR_NOTIFICATION_VEHICLE = '7' 162 | ECO_CHALLENGE = '721' 163 | ECO_CHALLENGE_FLEET = '722' 164 | MOBILE_INFORMATION = '74' 165 | URL_PRESET_ON_VEHICLE = '77' 166 | ASSISTED_DESTINATION_SETTING = '78' 167 | CONCIERGE = '79' 168 | PERSONAL_DATA_SYNC = '80' 169 | THEFT_BURGLAR_NOTIFICATION_APP = '87' 170 | STOLEN_VEHICLE_TRACKING_SVT = '9' 171 | REMOTE_ENGINE_START = '96' 172 | HORN_AND_LIGHTS = '97' 173 | CURFEW_ALERT = '98' 174 | TEMPERATURE = '2042' 175 | VALET_PARKING_CALL = '401' 176 | PANIC_CALL = '406' 177 | VIRTUAL_PERSONAL_ASSISTANT = '734' 178 | ALEXA_ONBOARD_ASSISTANT = '736' 179 | SCHEDULED_ROUTE_CLIMATE_CONTROL = '747' 180 | SCHEDULED_ROUTE_CALENDAR_INTERGRATION = '819' 181 | OWNER_MANUAL = '827' 182 | 183 | 184 | class Language(enum.Enum): 185 | """The service requires ISO 639-1 language codes to be mapped back 186 | to ISO 3166-1 country codes. Of course. 187 | """ 188 | 189 | # Bulgarian = Bulgaria 190 | BG = 'BG' 191 | # Czech = Czech Republic 192 | CS = 'CZ' 193 | # Danish = Denmark 194 | DA = 'DK' 195 | # German = Germany 196 | DE = 'DE' 197 | # Greek = Greece 198 | EL = 'GR' 199 | # Spanish = Spain 200 | ES = 'ES' 201 | # Finnish = Finland 202 | FI = 'FI' 203 | # French = France 204 | FR = 'FR' 205 | # Hebrew = Israel 206 | HE = 'IL' 207 | # Croatian = Croatia 208 | HR = 'HR' 209 | # Hungarian = Hungary 210 | HU = 'HU' 211 | # Italian = Italy 212 | IT = 'IT' 213 | # Formal Norwegian = Norway 214 | NB = 'NO' 215 | # Dutch = Netherlands 216 | NL = 'NL' 217 | # Polish = Poland 218 | PL = 'PL' 219 | # Portuguese = Portugal 220 | PT = 'PT' 221 | # Romanian = Romania 222 | RO = 'RO' 223 | # Russian = Russia 224 | RU = 'RU' 225 | # Slovakian = Slovakia 226 | SK = 'SK' 227 | # Slovenian = Slovenia 228 | SI = 'SL' 229 | # Serbian = Serbia 230 | SR = 'RS' 231 | # Swedish = Sweden 232 | SV = 'SE' 233 | # Ukranian = Ukraine 234 | UK = 'UA' 235 | # Default 236 | EN = 'EN' 237 | 238 | 239 | class Order(enum.Enum): 240 | DESC = 'DESC' 241 | ASC = 'ASC' 242 | 243 | 244 | class NotificationCategoryKey(enum.Enum): 245 | ASSISTANCE = 'assistance' 246 | CHARGE_EV = 'chargeev' 247 | CUSTOM = 'custom' 248 | EV_BATTERY = 'EVBattery' 249 | FOTA = 'fota' 250 | GEO_FENCING = 'geofencing' 251 | MAINTENANCE = 'maintenance' 252 | NAVIGATION = 'navigation' 253 | PRIVACY_MODE = 'privacymode' 254 | REMOTE_CONTROL = 'remotecontrol' 255 | RESET = 'RESET' 256 | RGDC = 'rgdcmyze' 257 | SAFETY_AND_SECURITY = 'Safety&Security' 258 | SVT = 'SVT' 259 | 260 | 261 | class NotificationStatus(enum.Enum): 262 | READ = 'READ' 263 | UNREAD = 'UNREAD' 264 | 265 | 266 | class NotificationChannelType(enum.Enum): 267 | PUSH_APP = 'PUSH_APP' 268 | MAIL = 'MAIL' 269 | OFF = '' 270 | SMS = 'SMS' 271 | 272 | class NotificationTypeKey(enum.Enum): 273 | ABS_ALERT = 'abs.alert' 274 | AVAILABLE_CHARGING = 'available.charging' 275 | BADGE_BATTERY_ALERT = 'badge.battery.alert' 276 | BATTERY_BLOWING_REQUEST = 'battery.blowing.request' 277 | BATTERY_CHARGE_AVAILABLE = 'battery.charge.available' 278 | BATTERY_CHARGE_IN_PROGRESS = 'battery.charge.in.progress' 279 | BATTERY_CHARGE_UNAVAILABLE = 'battery.charge.unavailable' 280 | BATTERY_COOLING_CONDITIONNING_REQUEST = 'battery.cooling.conditionning.request' 281 | BATTERY_ENDED_CHARGE = 'battery.ended.charge' 282 | BATTERY_FLAP_OPENED = 'battery.flap.opened' 283 | BATTERY_FULL_EXCEPTION = 'battery.full.exception' 284 | BATTERY_HEATING_CONDITIONNING_REQUEST = 'battery.heating.conditionning.request' 285 | BATTERY_HEATING_START = 'battery.heating.start' 286 | BATTERY_HEATING_STOP = 'battery.heating.stop' 287 | BATTERY_PREHEATING_START = 'battery.preheating.start' 288 | BATTERY_PREHEATING_STOP = 'battery.preheating.stop' 289 | BATTERY_SCHEDULE_ISSUE = 'battery.schedule.issue' 290 | BATTERY_TEMPERATURE_ALERT = 'battery.temperature.alert' 291 | BATTERY_WAITING_CURRENT_CHARGE = 'battery.waiting.current.charge' 292 | BATTERY_WAITING_PLANNED_CHARGE = 'battery.waiting.planned.charge' 293 | BRAKE_ALERT = 'brake.alert' 294 | BRAKE_SYSTEM_MALFUNCTION = 'brake.system.malfunction' 295 | BURGLAR_ALARM_LOST = 'burglar.alarm.lost' 296 | BURGLAR_CAR_STOLEN = 'burglar.car.stolen' 297 | BURGLAR_TOW_INFO = 'burglar.tow.info' 298 | CHARGE_FAILURE = 'charge.failure' 299 | CHARGE_NOT_PROHIBITED = 'charge.not.prohibited' 300 | CHARGE_PROHIBITED = 'charge.prohibited' 301 | CHARGING_STOP_GEN3 = 'charging.stop.gen3' 302 | COOLANT_ALERT = 'coolant.alert' 303 | CRASH_DETECTION_ALERT = 'crash.detection.alert' 304 | CURFEW_INFRINGEMENT = 'curfew.infringement' 305 | CURFEW_RECOVERY = 'curfew.recovery' 306 | CUSTOM = 'custom' 307 | DURING_INHIBITED_CHARGING = 'during.inhibited.charging' 308 | ENGINE_WATER_TEMP_ALERT = 'engine.water.temp.alert' 309 | EPS_ALERT = 'eps.alert' 310 | FOTA_CAMPAIGN_AVAILABLE = 'fota.campaign.available' 311 | FOTA_CAMPAIGN_STATUS_ACTIVATION_COMPLETED = 'fota.campaign.status.activation.completed' 312 | FOTA_CAMPAIGN_STATUS_ACTIVATION_FAILED = 'fota.campaign.status.activation.failed' 313 | FOTA_CAMPAIGN_STATUS_ACTIVATION_POSTPONED = 'fota.campaign.status.activation.postponed' 314 | FOTA_CAMPAIGN_STATUS_ACTIVATION_PROGRESS = 'fota.campaign.status.activation.progress' 315 | FOTA_CAMPAIGN_STATUS_ACTIVATION_SCHEDULED = 'fota.campaign.status.activation.scheduled' 316 | FOTA_CAMPAIGN_STATUS_CANCELLED = 'fota.campaign.status.cancelled' 317 | FOTA_CAMPAIGN_STATUS_CANCELLING = 'fota.campaign.status.cancelling' 318 | FOTA_CAMPAIGN_STATUS_DOWNLOAD_COMPLETED = 'fota.campaign.status.download.completed' 319 | FOTA_CAMPAIGN_STATUS_DOWNLOAD_PAUSED = 'fota.campaign.status.download.paused' 320 | FOTA_CAMPAIGN_STATUS_DOWNLOAD_PROGRESS = 'fota.campaign.status.download.progress' 321 | FOTA_CAMPAIGN_STATUS_INSTALLATION_COMPLETED = 'fota.campaign.status.installation.completed' 322 | FOTA_CAMPAIGN_STATUS_INSTALLATION_PROGRESS = 'fota.campaign.status.installation.progress' 323 | FUEL_ALERT = 'fuel.alert' 324 | HVAC_AUTOSTART = 'hvac.autostart' 325 | HVAC_AUTOSTOP = 'hvac.autostop' 326 | HVAC_TECHNICAL_ISSUE = 'hvac.technical.issue' 327 | HVAC_TRACTION_BATTERY_LOW = 'hvac.traction.battery.low' 328 | HVAC_VEHICLE_IN_USE = 'hvac.vehicle.in.use' 329 | HVAC_VEHICLE_NOT_CONNECTED_POWER = 'hvac.vehicle.not.connected.power' 330 | LAST_MILE_DESTINATION_ADDRESS = 'last.mile.destination.address' 331 | LOCK_STATUS_REMINDER = 'lock.status.reminder' 332 | MAINTENANCE_DISTANCE_PREALERT = 'maintenance.distance.prealert' 333 | MAINTENANCE_TIME_PREALERT = 'maintenance.time.prealert' 334 | MIL_LAMP_AUTO_TEST = 'mil.lamp.auto.test' 335 | MIL_LAMP_FLASH_REQUEST = 'mil.lamp.flash.request' 336 | MIL_LAMP_OFF_REQUEST = 'mil.lamp.off.request' 337 | MIL_LAMP_ON_REQUEST = 'mil.lamp.on.request' 338 | NEXT_CHARGING_INHIBITED = 'next.charging.inhibited' 339 | OIL_LEVEL_ALERT = 'oil.level.alert' 340 | OIL_PRESSURE_ALERT = 'oil.pressure.alert' 341 | OUT_OF_PARK_POSITION_CHARGE_INTERRUPTION = 'out.of.park.position.charge.interruption' 342 | PLUG_CONNECTION_ISSUE = 'plug.connection.issue' 343 | PLUG_CONNECTION_SUCCESS = 'plug.connection.success' 344 | PLUG_UNLOCKING = 'plug.unlocking' 345 | PREREMINDER_ALERT_DEFAULT = 'prereminder.alert.default' 346 | PRIVACY_MODE_OFF = 'privacy.mode.off' 347 | PRIVACY_MODE_ON = 'privacy.mode.on' 348 | PROGRAMMED_CHARGE_INTERRUPTION = 'programmed.charge.interruption' 349 | PROHIBITION_BATTERY_RENTAL = 'prohibition.battery.rental' 350 | PWT_START_IMPOSSIBLE = 'pwt.start.impossible' 351 | REMOTE_LEFT_TIME_CYCLE = 'remote.left.time.cycle' 352 | REMOTE_START_CUSTOMER = 'remote.start.customer' 353 | REMOTE_START_ENGINE = 'remote.start.engine' 354 | REMOTE_START_NORMAL_ONLY = 'remote.start.normal.only' 355 | REMOTE_START_PHONE_ERROR = 'remote.start.phone.error' 356 | REMOTE_START_UNAVAILABLE = 'remote.start.unavailable' 357 | REMOTE_START_WAIT_PRESOAK = 'remote.start.wait.presoak' 358 | SERV_WARNING_ALERT = 'serv.warning.alert' 359 | SPEED_INFRINGEMENT = 'speed.infringement' 360 | SPEED_RECOVERY = 'speed.recovery' 361 | START_DRIVING_CHARGE_INTERRUPTION = 'start.driving.charge.interruption' 362 | START_IN_PROGRESS = 'start.in.progress' 363 | STATUS_OIL_PRESSURE_SWITCH_CLOSED = 'status.oil.pressure.switch.closed' 364 | STATUS_OIL_PRESSURE_SWITCH_OPEN = 'status.oil.pressure.switch.open' 365 | STOP_WARNING_ALERT = 'stop.warning.alert' 366 | UNPLUG_CHARGE = 'unplug.charge' 367 | WAITING_PLANNED_CHARGE = 'waiting.planned.charge' 368 | WHEEL_ALERT = 'wheel.alert' 369 | ZONE_INFRINGEMENT = 'zone.infringement' 370 | ZONE_RECOVERY = 'zone.recovery' 371 | 372 | 373 | class NotificationRuleKey(enum.Enum): 374 | ABS_ALERT = 'abs.alert' 375 | AVAILABLE_CHARGING = 'available.charging' 376 | BADGE_BATTERY_ALERT = 'badge.battery.alert' 377 | BATTERY_BLOWING_REQUEST = 'battery.blowing.request' 378 | BATTERY_CHARGE_AVAILABLE = 'battery.charge.available' 379 | BATTERY_CHARGE_IN_PROGRESS = 'battery.charge.in.progress' 380 | BATTERY_CHARGE_UNAVAILABLE = 'battery.charge.unavailable' 381 | BATTERY_COOLING_CONDITIONNING_REQUEST = 'battery.cooling.conditionning.request' 382 | BATTERY_ENDED_CHARGE = 'battery.ended.charge' 383 | BATTERY_FLAP_OPENED = 'battery.flap.opened' 384 | BATTERY_FULL_EXCEPTION = 'battery.full.exception' 385 | BATTERY_HEATING_CONDITIONNING_REQUEST = 'battery.heating.conditionning.request' 386 | BATTERY_HEATING_START = 'battery.heating.start' 387 | BATTERY_HEATING_STOP = 'battery.heating.stop' 388 | BATTERY_PREHEATING_START = 'battery.preheating.start' 389 | BATTERY_PREHEATING_STOP = 'battery.preheating.stop' 390 | BATTERY_SCHEDULE_ISSUE = 'battery.schedule.issue' 391 | BATTERY_TEMPERATURE_ALERT = 'battery.temperature.alert' 392 | BATTERY_WAITING_CURRENT_CHARGE = 'battery.waiting.current.charge' 393 | BATTERY_WAITING_PLANNED_CHARGE = 'battery.waiting.planned.charge' 394 | BRAKE_ALERT = 'brake.alert' 395 | BRAKE_SYSTEM_MALFUNCTION = 'brake.system.malfunction' 396 | BURGLAR_ALARM_LOST = 'burglar.alarm.lost' 397 | BURGLAR_CAR_STOLEN = 'burglar.car.stolen' 398 | BURGLAR_TOW_INFO = 'burglar.tow.info' 399 | BURGLAR_TOW_SYSTEM_FAILURE = 'burglar.tow.system.failure' 400 | CHARGE_FAILURE = 'charge.failure' 401 | CHARGE_NOT_PROHIBITED = 'charge.not.prohibited' 402 | CHARGE_PROHIBITED = 'charge.prohibited' 403 | CHARGING_STOP_GEN3 = 'charging.stop.gen3' 404 | COOLANT_ALERT = 'coolant.alert' 405 | CRASH_DETECTION_ALERT = 'crash.detection.alert' 406 | CURFEW_INFRINGEMENT = 'curfew.infringement' 407 | CURFEW_RECOVERY = 'curfew.recovery' 408 | CUSTOM = 'custom' 409 | DURING_INHIBITED_CHARGING = 'during.inhibited.charging' 410 | ENGINE_WATER_TEMP_ALERT = 'engine.water.temp.alert' 411 | EPS_ALERT = 'eps.alert' 412 | FOTA_CAMPAIGN_AVAILABLE = 'fota.campaign.available' 413 | FOTA_CAMPAIGN_STATUS_ACTIVATION_COMPLETED = 'fota.campaign.status.activation.completed' 414 | FOTA_CAMPAIGN_STATUS_ACTIVATION_FAILED = 'fota.campaign.status.activation.failed' 415 | FOTA_CAMPAIGN_STATUS_ACTIVATION_POSTPONED = 'fota.campaign.status.activation.postponed' 416 | FOTA_CAMPAIGN_STATUS_ACTIVATION_PROGRESS = 'fota.campaign.status.activation.progress' 417 | FOTA_CAMPAIGN_STATUS_ACTIVATION_SCHEDULED = 'fota.campaign.status.activation.scheduled' 418 | FOTA_CAMPAIGN_STATUS_CANCELLED = 'fota.campaign.status.cancelled' 419 | FOTA_CAMPAIGN_STATUS_CANCELLING = 'fota.campaign.status.cancelling' 420 | FOTA_CAMPAIGN_STATUS_DOWNLOAD_COMPLETED = 'fota.campaign.status.download.completed' 421 | FOTA_CAMPAIGN_STATUS_DOWNLOAD_PAUSED = 'fota.campaign.status.download.paused' 422 | FOTA_CAMPAIGN_STATUS_DOWNLOAD_PROGRESS = 'fota.campaign.status.download.progress' 423 | FOTA_CAMPAIGN_STATUS_INSTALLATION_COMPLETED = 'fota.campaign.status.installation.completed' 424 | FOTA_CAMPAIGN_STATUS_INSTALLATION_PROGRESS = 'fota.campaign.status.installation.progress' 425 | FUEL_ALERT = 'fuel.alert' 426 | HVAC_AUTOSTART = 'hvac.autostart' 427 | HVAC_AUTOSTOP = 'hvac.autostop' 428 | HVAC_TECHNICAL_ISSUE = 'hvac.technical.issue' 429 | HVAC_TRACTION_BATTERY_LOW = 'hvac.traction.battery.low' 430 | HVAC_VEHICLE_IN_USE = 'hvac.vehicle.in.use' 431 | HVAC_VEHICLE_NOT_CONNECTED_POWER = 'hvac.vehicle.not.connected.power' 432 | LAST_MILE_DESTINATION_ADDRESS = 'last.mile.destination.address' 433 | LOCK_STATUS_REMINDER = 'lock.status.reminder' 434 | MAINTENANCE_DISTANCE_PREALERT = 'maintenance.distance.prealert' 435 | MAINTENANCE_TIME_PREALERT = 'maintenance.time.prealert' 436 | MIL_LAMP_AUTO_TEST = 'mil.lamp.auto.test' 437 | MIL_LAMP_FLASH_REQUEST = 'mil.lamp.flash.request' 438 | MIL_LAMP_OFF_REQUEST = 'mil.lamp.off.request' 439 | MIL_LAMP_ON_REQUEST = 'mil.lamp.on.request' 440 | NEXT_CHARGING_INHIBITED = 'next.charging.inhibited' 441 | OIL_LEVEL_ALERT = 'oil.level.alert' 442 | OIL_PRESSURE_ALERT = 'oil.pressure.alert' 443 | OUT_OF_PARK_POSITION_CHARGE_INTERRUPTION = 'out.of.park.position.charge.interruption' 444 | PLUG_CONNECTION_ISSUE = 'plug.connection.issue' 445 | PLUG_CONNECTION_SUCCESS = 'plug.connection.success' 446 | PLUG_UNLOCKING = 'plug.unlocking' 447 | PREREMINDER_ALERT_DEFAULT = 'prereminder.alert.default' 448 | PRIVACY_MODE_OFF = 'privacy.mode.off' 449 | PRIVACY_MODE_ON = 'privacy.mode.on' 450 | PROGRAMMED_CHARGE_INTERRUPTION = 'programmed.charge.interruption' 451 | PROHIBITION_BATTERY_RENTAL = 'prohibition.battery.rental' 452 | PWT_START_IMPOSSIBLE = 'pwt.start.impossible' 453 | REMOTE_LEFT_TIME_CYCLE = 'remote.left.time.cycle' 454 | REMOTE_START_CUSTOMER = 'remote.start.customer' 455 | REMOTE_START_ENGINE = 'remote.start.engine' 456 | REMOTE_START_NORMAL_ONLY = 'remote.start.normal.only' 457 | REMOTE_START_PHONE_ERROR = 'remote.start.phone.error' 458 | REMOTE_START_UNAVAILABLE = 'remote.start.unavailable' 459 | REMOTE_START_WAIT_PRESOAK = 'remote.start.wait.presoak' 460 | RENAULT_RESET_FACTORY = 'renault.reset.factory' 461 | RGDC_CHARGE_COMPLETE = 'rgdc.charge.complete' 462 | RGDC_CHARGE_ERROR = 'rgdc.charge.error' 463 | RGDC_CHARGE_ON = 'rgdc.charge.on' 464 | RGDC_CHARGE_STATUS = 'rgdc.charge.status' 465 | RGDC_LOW_BATTERY_ALERT = 'rgdc.low.battery.alert' 466 | RGDC_LOW_BATTERY_REMINDER = 'rgdc.low.battery.reminder' 467 | SERV_WARNING_ALERT = 'serv.warning.alert' 468 | SPEED_INFRINGEMENT = 'speed.infringement' 469 | SPEED_RECOVERY = 'speed.recovery' 470 | SRP_PINCODE_ACKNOWLEDGEMENT = 'srp.pincode.acknowledgement' 471 | SRP_PINCODE_DELETION = 'srp.pincode.deletion' 472 | SRP_PINCODE_STATUS = 'srp.pincode.status' 473 | SRP_SALT_REQUEST = 'srp.salt.request' 474 | START_DRIVING_CHARGE_INTERRUPTION = 'start.driving.charge.interruption' 475 | START_IN_PROGRESS = 'start.in.progress' 476 | STATUS_OIL_PRESSURE_SWITCH_CLOSED = 'status.oil.pressure.switch.closed' 477 | STATUS_OIL_PRESSURE_SWITCH_OPEN = 'status.oil.pressure.switch.open' 478 | STOLEN_VEHICLE_TRACKING = 'stolen.vehicle.tracking' 479 | STOLEN_VEHICLE_TRACKING_BLOCKING = 'stolen.vehicle.tracking.blocking' 480 | STOP_WARNING_ALERT = 'stop.warning.alert' 481 | SVT_SERVICE_ACTIVATION = 'svt.service.activation' 482 | UNPLUG_CHARGE = 'unplug.charge' 483 | WAITING_PLANNED_CHARGE = 'waiting.planned.charge' 484 | WHEEL_ALERT = 'wheel.alert' 485 | ZONE_INFRINGEMENT = 'zone.infringement' 486 | ZONE_RECOVERY = 'zone.recovery' 487 | 488 | 489 | class NotificationPriority(enum.Enum): 490 | 491 | NONE = None 492 | P0 = 0 493 | P1 = 1 494 | P2 = 2 495 | P3 = 3 496 | 497 | 498 | class NotificationRuleStatus(enum.Enum): 499 | ACTIVATED = 'ACTIVATED' 500 | ACTIVATION_IN_PROGRESS = 'STATUS_ACTIVATION_IN_PROGRESS' 501 | DELETION_IN_PROGRESS = 'STATUS_DELETION_IN_PROGRESS' 502 | -------------------------------------------------------------------------------- /custom_components/nissan_connect/kamereon/kamereon.py: -------------------------------------------------------------------------------- 1 | # Based on work by @mitchellrj and @Tobiaswk 2 | # Portions re-licensed from Apache License, Version 2.0 with permission 3 | 4 | import collections 5 | import datetime 6 | import json 7 | import os 8 | import logging 9 | from typing import List 10 | import requests 11 | import time 12 | from oauthlib.common import generate_nonce 13 | from oauthlib.oauth2 import TokenExpiredError 14 | from requests_oauthlib import OAuth2Session 15 | from .kamereon_const import * 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | _registry = { 20 | USERS: {}, 21 | VEHICLES: {}, 22 | CATEGORIES: {}, 23 | NOTIFICATION_RULES: {}, 24 | NOTIFICATION_TYPES: {}, 25 | NOTIFICATION_CATEGORIES: {}, 26 | } 27 | 28 | NotificationType = collections.namedtuple('NotificationType', ['key', 'title', 'message', 'category']) 29 | NotificationCategory = collections.namedtuple('Category', ['key', 'title']) 30 | 31 | class Notification: 32 | 33 | @property 34 | def vehicle(self): 35 | return _registry[VEHICLES][self.vin] 36 | 37 | @property 38 | def user_id(self): 39 | return self.vehicle.user_id 40 | 41 | @property 42 | def session(self): 43 | return self.vehicle.session 44 | 45 | def __init__(self, data, language, vin): 46 | self.language = language 47 | self.vin = vin 48 | self.id = data['notificationId'] 49 | self.title = data['messageTitle'] 50 | self.subtitle = data['messageSubtitle'] 51 | self.description = data['messageDescription'] 52 | self.category = NotificationCategoryKey(data['categoryKey']) 53 | self.rule_key = NotificationRuleKey(data['ruleKey']) 54 | self.notification_key = NotificationTypeKey(data['notificationKey']) 55 | self.priority = NotificationPriority(data['priority']) 56 | self.state = NotificationStatus(data['status']) 57 | t = datetime.datetime.strptime(data['timestamp'].split('.')[0], '%Y-%m-%dT%H:%M:%S') 58 | if '.' in data['timestamp']: 59 | fraction = data['timestamp'][20:-1] 60 | t = t.replace(microsecond=int(fraction) * 10**(6-len(fraction))) 61 | self.time = t 62 | # List of {'name': 'N', 'type': 'T', 'value': 'V'} 63 | self.data = data['data'] 64 | # future use maybe? empty dict 65 | self.metadata = data['metadata'] 66 | 67 | def __str__(self): 68 | # title is kinda useless, subtitle has better content 69 | return '{}: {}'.format(self.time, self.subtitle) 70 | 71 | def fetch_details(self, language: Language=None): 72 | if language is None: 73 | language = self.language 74 | resp = self._get( 75 | '{}v2/notifications/users/{}/vehicles/{}/notifications/{}'.format( 76 | self.session.settings['notifications_base_url'], 77 | self.user_id, self.vin, self.id 78 | ), 79 | params={'langCode': language.value} 80 | ) 81 | return resp 82 | 83 | 84 | class KamereonSession: 85 | 86 | tenant = None 87 | copy_realm = None 88 | unique_id = None 89 | 90 | def __init__(self, region, unique_id=None): 91 | self.settings = SETTINGS_MAP[self.tenant][region] 92 | session = requests.session() 93 | self.session = session 94 | self._oauth = None 95 | self._user_id = None 96 | self.unique_id = unique_id 97 | # ugly hack 98 | os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' 99 | 100 | def login(self, username=None, password=None): 101 | if username is not None and password is not None: 102 | # Cache credentials 103 | self._username = username 104 | self._password = password 105 | else: 106 | # Use cached credentials 107 | username = self._username 108 | password = self._password 109 | 110 | # Reset session 111 | self.session = requests.session() 112 | 113 | # grab an auth ID to use as part of the username/password login request, 114 | # then move to the regular OAuth2 process 115 | auth_url = '{}json/realms/root/realms/{}/authenticate'.format( 116 | self.settings['auth_base_url'], 117 | self.settings['realm'], 118 | ) 119 | resp = self.session.post( 120 | auth_url, 121 | headers={ 122 | 'Accept-Api-Version': API_VERSION, 123 | 'X-Username': 'anonymous', 124 | 'X-Password': 'anonymous', 125 | 'Accept': 'application/json', 126 | }) 127 | next_body = resp.json() 128 | 129 | # insert the username, and password 130 | for c in next_body['callbacks']: 131 | input_type = c['type'] 132 | if input_type == 'NameCallback': 133 | c['input'][0]['value'] = username 134 | elif input_type == 'PasswordCallback': 135 | c['input'][0]['value'] = password 136 | 137 | resp = self.session.post( 138 | auth_url, 139 | headers={ 140 | 'Accept-Api-Version': API_VERSION, 141 | 'X-Username': 'anonymous', 142 | 'X-Password': 'anonymous', 143 | 'Accept': 'application/json', 144 | 'Content-Type': 'application/json', 145 | }, 146 | data=json.dumps(next_body)) 147 | 148 | oauth_data = resp.json() 149 | 150 | if 'realm' not in oauth_data: 151 | _LOGGER.error("Invalid credentials provided: %s", resp.text) 152 | raise RuntimeError("Invalid credentials") 153 | 154 | oauth_authorize_url = '{}oauth2/{}/authorize'.format( 155 | self.settings['auth_base_url'], 156 | self.settings['realm'] 157 | ) 158 | nonce = generate_nonce() 159 | resp = self.session.get( 160 | oauth_authorize_url, 161 | params={ 162 | 'client_id': self.settings['client_id'], 163 | 'redirect_uri': self.settings['redirect_uri'], 164 | 'response_type': 'code', 165 | 'scope': self.settings['scope'], 166 | 'nonce': nonce, 167 | }, 168 | allow_redirects=False) 169 | oauth_authorize_url = resp.headers['location'] 170 | 171 | oauth_token_url = '{}oauth2/{}/access_token'.format( 172 | self.settings['auth_base_url'], 173 | self.settings['realm'] 174 | ) 175 | self._oauth = OAuth2Session( 176 | client_id=self.settings['client_id'], 177 | redirect_uri=self.settings['redirect_uri'], 178 | scope=self.settings['scope']) 179 | self._oauth._client.nonce = nonce 180 | self._oauth.fetch_token( 181 | oauth_token_url, 182 | authorization_response=oauth_authorize_url, 183 | client_secret=self.settings['client_secret'], 184 | include_client_id=True) 185 | 186 | @property 187 | def oauth(self): 188 | if self._oauth is None: 189 | raise RuntimeError('No access token set, you need to log in first.') 190 | return self._oauth 191 | 192 | @property 193 | def user_id(self): 194 | if not self._user_id: 195 | resp = self.oauth.get( 196 | '{}v1/users/current'.format(self.settings['user_adapter_base_url']) 197 | ) 198 | self._user_id = resp.json()['userId'] 199 | _registry[USERS][self._user_id] = self 200 | return self._user_id 201 | 202 | def fetch_vehicles(self): 203 | resp = self.oauth.get( 204 | '{}v5/users/{}/cars'.format(self.settings['user_base_url'], self.user_id) 205 | ) 206 | vehicles = [] 207 | for vehicle_data in resp.json()['data']: 208 | vehicle = Vehicle(vehicle_data, self.user_id) 209 | vehicles.append(vehicle) 210 | _registry[VEHICLES][vehicle.vin] = vehicle 211 | return vehicles 212 | 213 | 214 | class NCISession(KamereonSession): 215 | 216 | tenant = 'nissan' 217 | copy_realm = 'P_NCB' 218 | 219 | 220 | class Vehicle: 221 | 222 | def __repr__(self): 223 | return '<{} {}>'.format(self.__class__.__name__, self.vin) 224 | 225 | def __str__(self): 226 | return self.vin 227 | 228 | @property 229 | def session(self): 230 | return _registry[USERS][self.user_id] 231 | 232 | def __init__(self, data, user_id): 233 | self.user_id = user_id 234 | self.vin = data['vin'].upper() 235 | self.features = [] 236 | 237 | # Try to parse every feature, but dont fail if we dont recognise one 238 | for u in data.get('services', []): 239 | if u['activationState'] == "ACTIVATED": 240 | try: 241 | self.features.append(Feature(str(u['id']))) 242 | except ValueError: 243 | _LOGGER.debug(f"Unknown feature {str(u['id'])}") 244 | pass 245 | 246 | _LOGGER.debug("Active features: %s", self.features) 247 | 248 | self.can_generation = data.get('canGeneration') 249 | self.color = data.get('color') 250 | self.energy = data.get('energy') 251 | self.vehicle_gateway = data.get('carGateway') 252 | self.battery_code = data.get('batteryCode') 253 | self.engine_type = data.get('engineType') 254 | self.first_registration_date = data.get('firstRegistrationDate') 255 | self.ice_or_ev = data.get('iceEvFlag') 256 | self.model_name = data.get('modelName') 257 | self.model_code = data.get('modelCode') 258 | self.model_year = data.get('modelYear') 259 | self.nickname = data.get('nickname') 260 | self.phase = data.get('phase') 261 | self.picture_url = data.get('pictureURL') 262 | self.privacy_mode = data.get('privacyMode') 263 | self.registration_number = data.get('registrationNumber') 264 | self.battery_supported = True 265 | self.battery_capacity = None 266 | self.battery_level = None 267 | self.battery_temperature = None 268 | self.battery_bar_level = None 269 | self.instantaneous_power = None 270 | self.charging_speed = None 271 | self.charge_time_required_to_full = { 272 | ChargingSpeed.FAST: None, 273 | ChargingSpeed.NORMAL: None, 274 | ChargingSpeed.SLOW: None, 275 | ChargingSpeed.ADAPTIVE: None 276 | } 277 | self.range_hvac_off = None 278 | self.range_hvac_on = None 279 | self.charging = ChargingStatus.NOT_CHARGING 280 | self.plugged_in = PluggedStatus.NOT_PLUGGED 281 | self.plugged_in_time = None 282 | self.unplugged_time = None 283 | self.battery_status_last_updated = None 284 | self.location = None 285 | self.location_last_updated = None 286 | self.combustion_fuel_unit_cost = None 287 | self.electricity_unit_cost = None 288 | self.external_temperature = None 289 | self.internal_temperature = None 290 | self.hvac_status = None 291 | self.next_hvac_start_date = None 292 | self.next_target_temperature = None 293 | self.hvac_status_last_updated = None 294 | self.door_status = { 295 | Door.FRONT_LEFT: None, 296 | Door.FRONT_RIGHT: None, 297 | Door.REAR_LEFT: None, 298 | Door.REAR_RIGHT: None, 299 | Door.HATCH: None 300 | } 301 | self.lock_status = None 302 | self.lock_status_last_updated = None 303 | self.eco_score = None 304 | self.fuel_autonomy = None 305 | self.fuel_consumption = None 306 | self.fuel_economy = None 307 | self.fuel_level = None 308 | self.fuel_low_warning = None 309 | self.fuel_quantity = None 310 | self.mileage = None 311 | self.total_mileage = None 312 | 313 | def _request(self, method, url, headers=None, params=None, data=None, max_retries=3): 314 | for attempt in range(max_retries): 315 | try: 316 | if method == 'GET': 317 | resp = self.session.oauth.get(url, headers=headers, params=params) 318 | elif method == 'POST': 319 | resp = self.session.oauth.post(url, data=data, headers=headers) 320 | else: 321 | raise ValueError(f"Unsupported HTTP method: {method}") 322 | 323 | # Check for token expiration 324 | if resp.status_code == 401: 325 | raise TokenExpiredError() 326 | 327 | # Successful request 328 | return resp 329 | 330 | except TokenExpiredError: 331 | _LOGGER.debug("Token expired. Refreshing session and retrying.") 332 | self.session.login() 333 | except Exception as e: 334 | _LOGGER.debug(f"Request failed on attempt {attempt + 1} of {max_retries}: {e}") 335 | if attempt == max_retries - 1: # Exhausted retries 336 | raise 337 | time.sleep(2 ** attempt) # Exponential backoff on retry 338 | 339 | raise RuntimeError("Max retries reached, but the request could not be completed.") 340 | 341 | def _get(self, url, headers=None, params=None): 342 | return self._request('GET', url, headers=headers, params=params) 343 | 344 | def _post(self, url, data=None, headers=None): 345 | return self._request('POST', url, headers=headers, data=data) 346 | 347 | def refresh(self): 348 | self.refresh_location() 349 | self.refresh_battery_status() 350 | 351 | def fetch_all(self): 352 | self.fetch_cockpit() 353 | self.fetch_location() 354 | self.fetch_battery_status() 355 | self.fetch_hvac_status() 356 | self.fetch_lock_status() 357 | 358 | def refresh_location(self): 359 | if Feature.MY_CAR_FINDER not in self.features: 360 | return 361 | 362 | resp = self._post( 363 | '{}v1/cars/{}/actions/refresh-location'.format(self.session.settings['car_adapter_base_url'], self.vin), 364 | data=json.dumps({ 365 | 'data': {'type': 'RefreshLocation'} 366 | }), 367 | headers={'Content-Type': 'application/vnd.api+json'} 368 | ) 369 | body = resp.json() 370 | if 'errors' in body: 371 | raise ValueError(body['errors']) 372 | return body 373 | 374 | def fetch_location(self): 375 | if Feature.MY_CAR_FINDER not in self.features: 376 | return 377 | 378 | resp = self._get( 379 | '{}v1/cars/{}/location'.format(self.session.settings['car_adapter_base_url'], self.vin), 380 | headers={'Content-Type': 'application/vnd.api+json'} 381 | ) 382 | body = resp.json() 383 | if 'errors' in body: 384 | raise ValueError(body['errors']) 385 | location_data = body['data']['attributes'] 386 | self.location = (location_data['gpsLatitude'], location_data['gpsLongitude']) 387 | self.location_last_updated = datetime.datetime.fromisoformat(location_data['lastUpdateTime'].replace('Z','+00:00')) 388 | 389 | def refresh_lock_status(self): 390 | resp = self._post( 391 | '{}v1/cars/{}/actions/refresh-lock-status'.format(self.session.settings['car_adapter_base_url'], self.vin), 392 | data=json.dumps({ 393 | 'data': {'type': 'RefreshLockStatus'} 394 | }), 395 | headers={'Content-Type': 'application/vnd.api+json'} 396 | ) 397 | body = resp.json() 398 | if 'errors' in body: 399 | raise ValueError(body['errors']) 400 | return body 401 | 402 | def fetch_lock_status(self): 403 | if Feature.LOCK_STATUS_CHECK not in self.features: 404 | return 405 | resp = self._get( 406 | '{}v1/cars/{}/lock-status'.format(self.session.settings['car_adapter_base_url'], self.vin), 407 | headers={'Content-Type': 'application/vnd.api+json'} 408 | ) 409 | body = resp.json() 410 | if 'errors' in body: 411 | raise ValueError(body['errors']) 412 | lock_data = body['data']['attributes'] 413 | self.door_status[Door.FRONT_LEFT] = LockStatus(lock_data.get('doorStatusFrontLeft', LockStatus.CLOSED)) 414 | self.door_status[Door.FRONT_RIGHT] = LockStatus(lock_data.get('doorStatusFrontRight', LockStatus.CLOSED)) 415 | self.door_status[Door.REAR_LEFT] = LockStatus(lock_data.get('doorStatusRearLeft', LockStatus.CLOSED)) 416 | self.door_status[Door.REAR_RIGHT] = LockStatus(lock_data.get('doorStatusRearRight', LockStatus.CLOSED)) 417 | self.door_status[Door.HATCH] = LockStatus(lock_data.get('hatchStatus', LockStatus.CLOSED)) 418 | self.lock_status = LockStatus(lock_data.get('lockStatus', LockStatus.LOCKED)) 419 | self.lock_status_last_updated = datetime.datetime.fromisoformat(lock_data['lastUpdateTime'].replace('Z','+00:00')) 420 | 421 | def refresh_hvac_status(self): 422 | resp = self._post( 423 | '{}v1/cars/{}/actions/refresh-hvac-status'.format(self.session.settings['car_adapter_base_url'], self.vin), 424 | data=json.dumps({ 425 | 'data': {'type': 'RefreshHvacStatus'} 426 | }), 427 | headers={'Content-Type': 'application/vnd.api+json'} 428 | ) 429 | body = resp.json() 430 | if 'errors' in body: 431 | raise ValueError(body['errors']) 432 | return body 433 | 434 | def initiate_srp(self): 435 | (salt, verifier) = SRP.enroll(self.user_id, self.vin) 436 | resp = self._post( 437 | '{}v1/cars/{}/actions/srp-initiates'.format(self.session.settings['car_adapter_base_url'], self.vin), 438 | data=json.dumps({ 439 | "data": { 440 | "type": "SrpInitiates", 441 | "attributes": { 442 | "s": salt, 443 | "i": self.user_id, 444 | "v": verifier 445 | } 446 | } 447 | }), 448 | headers={'Content-Type': 'application/vnd.api+json'} 449 | ) 450 | body = resp.json() 451 | if 'errors' in body: 452 | raise ValueError(body['errors']) 453 | return body 454 | 455 | def validate_srp(self): 456 | a = SRP.generate_a() 457 | resp = self._post( 458 | '{}v1/cars/{}/actions/srp-sets'.format(self.session.settings['car_adapter_base_url'], self.vin), 459 | data=json.dumps({ 460 | "data": { 461 | "type": "SrpSets", 462 | "attributes": { 463 | "i": self.user_id, 464 | "a": a 465 | } 466 | } 467 | }), 468 | headers={'Content-Type': 'application/vnd.api+json'} 469 | ) 470 | body = resp.json() 471 | if 'errors' in body: 472 | raise ValueError(body['errors']) 473 | return body 474 | 475 | """ 476 | Other vehicle controls to implement / investigate: 477 | DataReset 478 | DeleteCurfewRestrictions 479 | CreateCurfewRestrictions 480 | CreateSpeedRestrictions 481 | SrpInitiates 482 | DeleteAreaRestrictions 483 | SrpDelete 484 | SrpSets 485 | OpenClose 486 | EngineStart 487 | LockUnlock 488 | CreateAreaRestrictions 489 | DeleteSpeedRestrictions 490 | """ 491 | 492 | def control_charging(self, action: str, srp: str=None): 493 | assert action in ('stop', 'start') 494 | if action == 'start' and Feature.CHARGING_START not in self.features: 495 | return 496 | if action == 'stop' and Feature.CHARGING_STOP not in self.features: 497 | return 498 | attributes = { 499 | 'action': action, 500 | } 501 | if srp is not None: 502 | attributes['srp'] = srp 503 | resp = self._post( 504 | '{}v1/cars/{}/actions/charging-start'.format(self.session.settings['car_adapter_base_url'], self.vin), 505 | data=json.dumps({ 506 | 'data': { 507 | 'type': 'ChargingStart', 508 | 'attributes': attributes 509 | } 510 | }), 511 | headers={'Content-Type': 'application/vnd.api+json'} 512 | ) 513 | body = resp.json() 514 | if 'errors' in body: 515 | raise ValueError(body['errors']) 516 | return body 517 | 518 | def control_horn_lights(self, action: str, target: str, duration: int=5, srp: str=None): 519 | if Feature.HORN_AND_LIGHTS not in self.features: 520 | return 521 | assert target in ('horn_lights', 'lights', 'horn') 522 | assert action in ('stop', 'start', 'double_start') 523 | attributes = { 524 | 'action': action, 525 | 'duration': duration, 526 | 'target': target, 527 | } 528 | if srp is not None: 529 | attributes['srp'] = srp 530 | resp = self._post( 531 | '{}v1/cars/{}/actions/horn-lights'.format(self.session.settings['car_adapter_base_url'], self.vin), 532 | data=json.dumps({ 533 | 'data': { 534 | 'type': 'HornLights', 535 | 'attributes': attributes 536 | } 537 | }), 538 | headers={'Content-Type': 'application/vnd.api+json'} 539 | ) 540 | body = resp.json() 541 | if 'errors' in body: 542 | raise ValueError(body['errors']) 543 | return body 544 | 545 | def set_hvac_status(self, action: HVACAction, target_temperature: int=21, start: datetime.datetime=None, srp: str=None): 546 | if Feature.CLIMATE_ON_OFF not in self.features: 547 | return 548 | 549 | if target_temperature < 16 or target_temperature > 26: 550 | raise ValueError('Temperature must be between 16 & 26 degrees') 551 | 552 | attributes = { 553 | 'action': action.value 554 | } 555 | if action == HVACAction.START: 556 | attributes['targetTemperature'] = target_temperature 557 | if start is not None: 558 | attributes['startDateTime'] = start.isoformat(timespec='seconds') 559 | if srp is not None: 560 | attributes['srp'] = srp 561 | 562 | resp = self._post( 563 | '{}v1/cars/{}/actions/hvac-start'.format(self.session.settings['car_adapter_base_url'], self.vin), 564 | data=json.dumps({ 565 | 'data': { 566 | 'type': 'HvacStart', 567 | 'attributes': attributes 568 | } 569 | }), 570 | headers={'Content-Type': 'application/vnd.api+json'} 571 | ) 572 | body = resp.json() 573 | if 'errors' in body: 574 | raise ValueError(body['errors']) 575 | return body 576 | 577 | def lock_unlock(self, srp: str, action: str, group: LockableDoorGroup=None): 578 | if Feature.APP_DOOR_LOCKING not in self.features: 579 | return 580 | assert action in ('lock', 'unlock') 581 | if group is None: 582 | group = LockableDoorGroup.DOORS_AND_HATCH 583 | resp = self._post( 584 | '{}v1/cars/{}/actions/lock-unlock"'.format(self.session.settings['car_adapter_base_url'], self.vin), 585 | data=json.dumps({ 586 | 'data': { 587 | 'type': 'LockUnlock', 588 | 'attributes': { 589 | 'lock': action, 590 | 'doorType': group.value, 591 | 'srp': srp 592 | } 593 | } 594 | }), 595 | headers={'Content-Type': 'application/vnd.api+json'} 596 | ) 597 | body = resp.json() 598 | if 'errors' in body: 599 | raise ValueError(body['errors']) 600 | return body 601 | 602 | def lock(self, srp: str, group: LockableDoorGroup=None): 603 | return self.lock_unlock(srp, 'lock', group) 604 | 605 | def unlock(self, srp: str, group: LockableDoorGroup=None): 606 | return self.lock_unlock(srp, 'unlock', group) 607 | 608 | def fetch_hvac_status(self): 609 | if Feature.INTERIOR_TEMP_SETTINGS not in self.features and Feature.TEMPERATURE not in self.features: 610 | return 611 | 612 | resp = self._get( 613 | '{}v1/cars/{}/hvac-status'.format(self.session.settings['car_adapter_base_url'], self.vin), 614 | headers={'Content-Type': 'application/vnd.api+json'} 615 | ) 616 | body = resp.json() 617 | if 'errors' in body: 618 | raise ValueError(body['errors']) 619 | hvac_data = body['data']['attributes'] 620 | self.external_temperature = hvac_data.get('externalTemperature') 621 | self.internal_temperature = hvac_data.get('internalTemperature') 622 | self.next_target_temperature = hvac_data.get('nextTargetTemperature') 623 | if 'hvacStatus' in hvac_data: 624 | self.hvac_status = hvac_data['hvacStatus'] == "on" 625 | if 'nextHvacStartDate' in hvac_data: 626 | self.next_hvac_start_date = datetime.datetime.fromisoformat(hvac_data['nextHvacStartDate'].replace('Z','+00:00')) 627 | if 'lastUpdateTime' in hvac_data: 628 | self.hvac_status_last_updated = datetime.datetime.fromisoformat(hvac_data['lastUpdateTime'].replace('Z','+00:00')) 629 | 630 | def refresh_battery_status(self): 631 | resp = self._post( 632 | '{}v1/cars/{}/actions/refresh-battery-status'.format(self.session.settings['car_adapter_base_url'], self.vin), 633 | data=json.dumps({ 634 | 'data': {'type': 'RefreshBatteryStatus'} 635 | }), 636 | headers={'Content-Type': 'application/vnd.api+json'} 637 | ) 638 | body = resp.json() 639 | if 'errors' in body: 640 | raise ValueError(body['errors']) 641 | return body 642 | 643 | def fetch_battery_status(self): 644 | self.fetch_battery_status_leaf() 645 | if self.model_name == "Ariya": 646 | self.fetch_battery_status_ariya() 647 | 648 | def fetch_battery_status_leaf(self): 649 | """The battery-status endpoint isn't just for EV's. ICE Nissans publish the range under this! 650 | There is no obvious feature to qualify this, so we just suck it and see.""" 651 | resp = self._get( 652 | '{}v1/cars/{}/battery-status'.format(self.session.settings['car_adapter_base_url'], self.vin), 653 | headers={'Content-Type': 'application/vnd.api+json'} 654 | ) 655 | body = resp.json() 656 | if 'errors' in body and Feature.BATTERY_STATUS in self.features: 657 | raise ValueError(body['errors']) 658 | 659 | if not 'data' in body or not 'attributes' in body['data']: 660 | self.battery_supported = False 661 | 662 | battery_data = body['data']['attributes'] 663 | self.battery_capacity = battery_data.get('batteryCapacity') # kWh 664 | self.battery_level = battery_data.get('batteryLevel') # % 665 | self.battery_temperature = battery_data.get('batteryTemperature') # Fahrenheit? 666 | # same meaning as battery level, different scale. 240 = 100% 667 | self.battery_bar_level = battery_data.get('batteryBarLevel') 668 | self.instantaneous_power = battery_data.get('instantaneousPower') # kW 669 | self.charging_speed = ChargingSpeed(battery_data.get('chargePower')) 670 | self.charge_time_required_to_full = { 671 | ChargingSpeed.FAST: battery_data.get('timeRequiredToFullFast'), 672 | ChargingSpeed.NORMAL: battery_data.get('timeRequiredToFullNormal'), 673 | ChargingSpeed.SLOW: battery_data.get('timeRequiredToFullSlow'), 674 | ChargingSpeed.ADAPTIVE: None 675 | } 676 | self.range_hvac_off = battery_data.get('rangeHvacOff') 677 | self.range_hvac_on = battery_data.get('rangeHvacOn') 678 | 679 | # For ICE vehicles, we should get the range at least. If not, dont bother again 680 | if self.range_hvac_on is None and Feature.BATTERY_STATUS not in self.features: 681 | self.battery_supported = False 682 | return 683 | 684 | self.charging = ChargingStatus(battery_data.get('chargeStatus', 0)) 685 | self.plugged_in = PluggedStatus(battery_data.get('plugStatus', 0)) 686 | if 'vehiclePlugTimestamp' in battery_data: 687 | self.plugged_in_time = datetime.datetime.fromisoformat(battery_data['vehiclePlugTimestamp'].replace('Z','+00:00')) 688 | if 'vehicleUnplugTimestamp' in battery_data: 689 | self.unplugged_time = datetime.datetime.fromisoformat(battery_data['vehicleUnplugTimestamp'].replace('Z','+00:00')) 690 | if 'lastUpdateTime' in battery_data: 691 | self.battery_status_last_updated = datetime.datetime.fromisoformat(battery_data['lastUpdateTime'].replace('Z','+00:00')) 692 | 693 | def fetch_battery_status_ariya(self): 694 | resp = self._get( 695 | '{}v3/cars/{}/battery-status?canGen={}'.format(self.session.settings['user_base_url'], self.vin, self.can_generation), 696 | headers={'Content-Type': 'application/vnd.api+json'} 697 | ) 698 | body = resp.json() 699 | if 'errors' in body and Feature.BATTERY_STATUS in self.features: 700 | raise ValueError(body['errors']) 701 | 702 | if not 'data' in body or not 'attributes' in body['data']: 703 | self.battery_supported = False 704 | 705 | battery_data = body['data']['attributes'] 706 | 707 | self.range_hvac_off = None 708 | self.range_hvac_on = battery_data.get('batteryAutonomy') or self.range_hvac_on 709 | 710 | self.charging_speed = ChargingSpeed(None) 711 | self.charge_time_required_to_full = { 712 | ChargingSpeed.FAST: None, 713 | ChargingSpeed.NORMAL: None, 714 | ChargingSpeed.SLOW: None, 715 | ChargingSpeed.ADAPTIVE: battery_data.get('chargingRemainingTime') or self.charge_time_required_to_full[ChargingSpeed.NORMAL] 716 | } 717 | 718 | self.plugged_in = PluggedStatus(battery_data.get('plugStatus', 0)) 719 | 720 | if 'vehiclePlugTimestamp' in battery_data: 721 | self.plugged_in_time = datetime.datetime.fromisoformat(battery_data['vehiclePlugTimestamp'].replace('Z','+00:00')) 722 | if 'vehicleUnplugTimestamp' in battery_data: 723 | self.unplugged_time = datetime.datetime.fromisoformat(battery_data['vehicleUnplugTimestamp'].replace('Z','+00:00')) 724 | if 'lastUpdateTime' in battery_data: 725 | self.battery_status_last_updated = datetime.datetime.fromisoformat(battery_data['lastUpdateTime'].replace('Z','+00:00')) 726 | 727 | def set_energy_unit_cost(self, cost): 728 | resp = self._post( 729 | '{}v1/cars/{}/energy-unit-cost'.format(self.session.settings['car_adapter_base_url'], self.vin), 730 | data=json.dumps({ 731 | 'data': { 732 | 'type': {} 733 | } 734 | }) 735 | ) 736 | body = resp.json() 737 | if 'errors' in body: 738 | raise ValueError(body['errors']) 739 | 740 | def fetch_trip_histories(self, period: Period=None, start: datetime.date=None, end: datetime.date=None): 741 | if period is None: 742 | period = Period.DAILY 743 | if start is None and end is None and period == Period.MONTHLY: 744 | end = datetime.datetime.utcnow().date() 745 | start = end.replace(day=1) 746 | elif start is None: 747 | start = datetime.datetime.utcnow().date() 748 | if end is None: 749 | end = start 750 | resp = self._get( 751 | '{}v1/cars/{}/trip-history'.format(self.session.settings['car_adapter_base_url'], self.vin), 752 | params={ 753 | 'type': period.value, 754 | 'start': start.isoformat(), 755 | 'end': end.isoformat() 756 | } 757 | ) 758 | body = resp.json() 759 | if 'errors' in body: 760 | raise ValueError(body['errors']) 761 | return [TripSummary(s, self.vin) for s in body['data']['attributes']['summaries']] 762 | 763 | def fetch_notifications( 764 | self, 765 | language: Language=None, 766 | category_key: NotificationCategoryKey=None, 767 | status: NotificationStatus=None, 768 | start: datetime.datetime=None, 769 | end: datetime.datetime=None, 770 | # offset 771 | from_: int=1, 772 | # limit 773 | to: int=20, 774 | order: Order=None 775 | ): 776 | 777 | if language is None: 778 | language = Language.EN 779 | params = { 780 | 'realm': self.session.copy_realm, 781 | 'langCode': language.value, 782 | } 783 | if category_key is not None: 784 | params['categoryKey'] = category_key.value 785 | if status is not None: 786 | params['status'] = status.value 787 | if start is not None: 788 | params['start'] = start.isoformat(timespec='seconds') 789 | if start.tzinfo is None: 790 | # Assume UTC 791 | params['start'] += 'Z' 792 | if end is not None: 793 | params['end'] = start.isoformat(timespec='seconds') 794 | if end.tzinfo is None: 795 | # Assume UTC 796 | params['end'] += 'Z' 797 | resp = self._get( 798 | '{}v2/notifications/users/{}/vehicles/{}'.format(self.session.settings['notifications_base_url'], self.user_id, self.vin), 799 | params=params 800 | ) 801 | body = resp.json() 802 | if 'errors' in body: 803 | raise ValueError(body['errors']) 804 | return [Notification(m, language, self.vin) for m in body['data']['attributes']['messages']] 805 | 806 | def mark_notifications(self, messages: List[Notification]): 807 | """Take a list of notifications and set their status remotely 808 | to the one held locally (read / unread).""" 809 | 810 | resp = self._post( 811 | '{}v2/notifications/users/{}/vehicles/{}'.format(self.session.settings['notifications_base_url'], self.user_id, self.vin), 812 | data=json.dumps([ 813 | {'notificationId': m.id, 'status': m.status.value} 814 | for m in messages 815 | ]) 816 | ) 817 | body = resp.json() 818 | if 'errors' in body: 819 | raise ValueError(body['errors']) 820 | return body 821 | 822 | def fetch_notification_settings(self, language: Language=None): 823 | if language is None: 824 | language = Language.EN 825 | params = { 826 | 'langCode': language.value, 827 | } 828 | resp = self._get( 829 | '{}v1/rules/settings/users/{}/vehicles/{}'.format(self.session.settings['notifications_base_url'], self.user_id, self.vin), 830 | params=params 831 | ) 832 | body = resp.json() 833 | if 'errors' in body: 834 | raise ValueError(body['errors']) 835 | return [ 836 | NotificationRule(r, language, self.vin) 837 | for r in body['settings'] 838 | ] 839 | 840 | def update_notification_settings(self): 841 | # TODO 842 | pass 843 | 844 | def fetch_cockpit(self): 845 | resp = self._get( 846 | "{}v1/cars/{}/cockpit".format(self.session.settings['car_adapter_base_url'], self.vin) 847 | ) 848 | body = resp.json() 849 | if 'errors' in body: 850 | raise ValueError(body['errors']) 851 | 852 | cockpit_data = body['data']['attributes'] 853 | self.eco_score = cockpit_data.get('ecoScore') 854 | self.fuel_autonomy = cockpit_data.get('fuelAutonomy') 855 | self.fuel_consumption = cockpit_data.get('fuelConsumption') 856 | self.fuel_economy = cockpit_data.get('fuelEconomy') 857 | self.fuel_level = cockpit_data.get('fuelLevel') 858 | if 'fuelLowWarning' in cockpit_data: 859 | self.fuel_low_warning = bool(cockpit_data.get('fuelLowWarning', False)) 860 | self.fuel_quantity = cockpit_data.get('fuelQuantity') # litres 861 | self.mileage = cockpit_data.get('mileage') 862 | self.total_mileage = cockpit_data.get('totalMileage') 863 | 864 | 865 | class TripSummary: 866 | 867 | def __init__(self, data, vin): 868 | self.vin = vin 869 | self.trip_count = data['tripsNumber'] 870 | self.total_distance = data['distance'] # km 871 | self.total_duration = data['duration'] # minutes 872 | self.first_trip_start = datetime.datetime.fromisoformat(data['firstTripStart'].replace('Z','+00:00')) 873 | self.last_trip_end = datetime.datetime.fromisoformat(data['lastTripEnd'].replace('Z','+00:00')) 874 | self.consumed_fuel = data['consumedFuel'] # litres 875 | self.consumed_electricity = data['consumedElectricity'] # W 876 | self.saved_electricity = data['savedElectricity'] # W 877 | if 'day' in data: 878 | self.start = self.end = datetime.date(int(data['day'][:4]), int(data['day'][4:6]), int(data['day'][6:])) 879 | elif 'month' in data: 880 | start_year = int(data['month'][:4]) 881 | start_month = int(data['month'][4:]) 882 | end_month = start_month + 1 883 | end_year = start_year 884 | if end_month > 12: 885 | end_month = 1 886 | end_year = end_year + 1 887 | self.start = datetime.date(start_year, start_month, 1) 888 | self.end = datetime.date(end_year, end_month, 1) - datetime.timedelta(days=1) 889 | elif 'year' in data: 890 | self.start = datetime.date(int(data['year']), 1, 1) 891 | self.end = datetime.date(int(data['year']) + 1, 1, 1) - datetime.timedelta(days=1) 892 | 893 | def __str__(self): 894 | return '{} trips covering {} kilometres over {} minutes using {} litres fuel and {} kilowatt-hours electricity'.format( 895 | self.trip_count, self.total_distance, self.total_duration, self.consumed_fuel, self.consumed_electricity 896 | ) 897 | 898 | 899 | class NotificationRule: 900 | 901 | def __init__(self, data, language, vin): 902 | self.vin = vin 903 | self.language = language 904 | self.key = NotificationRuleKey(data['ruleKey']) 905 | self.title = data['ruleTitle'] 906 | self.description = data['ruleDescription'] 907 | self.priority = NotificationPriority(data['priority']) 908 | self.status = NotificationRuleStatus(data['status']) 909 | self.channels = [ 910 | NotificationChannelType(c['channelType']) 911 | for c in data['channels'] 912 | ] 913 | self.category = NotificationCategory(NotificationCategoryKey(data['categoryKey']), data['categoryTitle']) 914 | self.notification_type = None 915 | if 'notificationKey' in data: 916 | self.notification_type = NotificationType( 917 | NotificationTypeKey(data['notificationKey']), 918 | data['notificationTitle'], 919 | data['notificationMessage'], 920 | self.category, 921 | ) 922 | 923 | def __str__(self): 924 | return '{}: {} ({})'.format( 925 | self.title or self.key, 926 | self.status.value, 927 | ', '.join(c.value for c in self.channels) 928 | ) 929 | 930 | 931 | class SRP: 932 | 933 | @classmethod 934 | def enroll(cls, user_id, vin): 935 | salt, verifier = '0'*20, 'ABCDEFGH'*64 936 | # salt = 20 hex chars, verifier = 512 hex chars 937 | return (salt, verifier) 938 | 939 | @classmethod 940 | def generate_a(cls): 941 | # 512 hex chars 942 | return '' 943 | 944 | @classmethod 945 | def generate_proof(cls, salt, b, user_id, confirm_code, order): 946 | """Required for remote lock / unlock.""" 947 | # order = '/' 948 | # where PERMISSIONS is one of: 949 | # * "BCI/Block" 950 | # * "BCI/Unblock" 951 | # * "RC/Delayed" 952 | # * "RC/Start" 953 | # * "RC/Stop" 954 | # * "RES/DoubleStart" 955 | # * "RES/Start" 956 | # * "RES/Stop" 957 | # * "RHL/Start/HornOnly" 958 | # * "RHL/Start/HornLight" 959 | # * "RHL/Start/LightOnly" 960 | # * "RHL/Stop" 961 | # * "RLU/Lock" 962 | # * "RLU/Unlock" 963 | # * "RPC_ICE/Start" 964 | # * "RPC_ICE/Stop" 965 | # * "RPU_CCS/Disable" 966 | # * "RPU_CCS/Enable" 967 | # * "RPU_SVTB/Disable" 968 | # * "RPU_SVTB/Enable" 969 | pass 970 | --------------------------------------------------------------------------------