From e42521c202c60cb3d21d7bb6ed6fedaf9c377a42 Mon Sep 17 00:00:00 2001 From: Tuen Lee Date: Sat, 18 Nov 2023 22:56:28 +0100 Subject: [PATCH 1/6] calculate fully charge time --- .../polestar_api/polestar_api.py | 2 ++ custom_components/polestar_api/sensor.py | 26 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/custom_components/polestar_api/polestar_api.py b/custom_components/polestar_api/polestar_api.py index 6f795a8..a40d3bd 100644 --- a/custom_components/polestar_api/polestar_api.py +++ b/custom_components/polestar_api/polestar_api.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +import json import logging from .const import ( ACCESS_TOKEN_MANAGER_ID, @@ -124,6 +125,7 @@ async def get_data(self, path, reponse_path=None): resp = await response.json(content_type=None) _LOGGER.debug(f"Response {resp}") + data = resp['data'] # add cache_data[path] diff --git a/custom_components/polestar_api/sensor.py b/custom_components/polestar_api/sensor.py index bc5395b..f62242d 100644 --- a/custom_components/polestar_api/sensor.py +++ b/custom_components/polestar_api/sensor.py @@ -1,8 +1,7 @@ -import json +from datetime import datetime, timedelta import logging from typing import Final from dataclasses import dataclass -from datetime import timedelta from .const import MAX_CHARGE_RANGE from .entity import TibberEVEntity @@ -76,7 +75,7 @@ class PolestarSensorDescription( path="{vin}/recharge-status", response_path="batteryChargeLevel.value", unit=PERCENTAGE, - round_digits=1, + round_digits=0, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.BATTERY, ), @@ -100,6 +99,17 @@ class PolestarSensorDescription( unit='Minutes', round_digits=None, ), + PolestarSensorDescription( + key="estimated_fully_charged_time", + name="Fully charged time", + icon="mdi:battery-clock", + path="{vin}/recharge-status", + response_path="estimatedChargingTime.value", + unit=None, + round_digits=None, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + ), PolestarSensorDescription( key="charging_connection_status", name="Charg. connection status", @@ -108,6 +118,7 @@ class PolestarSensorDescription( response_path="chargingConnectionStatus.value", unit=None, round_digits=None, + state_class=SensorStateClass.MEASUREMENT, ), PolestarSensorDescription( key="charging_system_status", @@ -212,13 +223,20 @@ def native_unit_of_measurement(self) -> str | None: @property def state(self) -> StateType: """Return the state of the sensor.""" - # parse the long text with a shorter one from the dict if self.entity_description.key == 'charging_connection_status': return ChargingConnectionStatusDict.get(self._attr_native_value, self._attr_native_value) if self.entity_description.key == 'charging_system_status': return ChargingSystemStatusDict.get(self._attr_native_value, self._attr_native_value) + # Custom state for estimated_fully_charged_time + if self.entity_description.key == 'estimated_fully_charged_time': + if self._attr_native_value is not None: + value = int(self._attr_native_value) + if value > 0: + return datetime.now().replace(second=0, microsecond=0) + timedelta(minutes=round(value)) + return 'Not charging' + # round the value if self.entity_description.round_digits is not None: if self._attr_native_value is not None: From 2b052b77d439e7273131126847ac9583e2f4b3ba Mon Sep 17 00:00:00 2001 From: Tuen Lee Date: Sun, 19 Nov 2023 00:10:15 +0100 Subject: [PATCH 2/6] Cleanup the code --- custom_components/polestar_api/__init__.py | 2 +- custom_components/polestar_api/config_flow.py | 6 +-- custom_components/polestar_api/const.py | 3 +- custom_components/polestar_api/entity.py | 7 ++- custom_components/polestar_api/polestar.py | 6 +-- .../polestar_api/polestar_api.py | 12 +++-- custom_components/polestar_api/sensor.py | 45 ++++++++++++------- 7 files changed, 45 insertions(+), 36 deletions(-) diff --git a/custom_components/polestar_api/__init__.py b/custom_components/polestar_api/__init__.py index 35f8647..ef00591 100644 --- a/custom_components/polestar_api/__init__.py +++ b/custom_components/polestar_api/__init__.py @@ -68,7 +68,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -async def tibber_setup(hass: HomeAssistant, name: str, username: str, password: str) -> PolestarApi | None: +async def polestar_setup(hass: HomeAssistant, name: str, username: str, password: str) -> PolestarApi | None: """Create a Polestar instance only once.""" try: diff --git a/custom_components/polestar_api/config_flow.py b/custom_components/polestar_api/config_flow.py index ecb1691..978c2eb 100644 --- a/custom_components/polestar_api/config_flow.py +++ b/custom_components/polestar_api/config_flow.py @@ -35,7 +35,7 @@ async def _create_entry(self, username: str, password: str, vin: str, vcc_api_ke } ) - async def _create_device(self, username: str, password: str, vin: str, vcc_api_key: str): + async def _create_device(self, username: str, password: str, vin: str, vcc_api_key: str) -> None: """Create device.""" try: @@ -59,7 +59,7 @@ async def _create_device(self, username: str, password: str, vin: str, vcc_api_k return await self._create_entry(username, password, vin, vcc_api_key) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input: dict = None) -> None: """User initiated config flow.""" if user_input is None: return self.async_show_form( @@ -72,6 +72,6 @@ async def async_step_user(self, user_input=None): ) return await self._create_device(user_input[CONF_USERNAME], user_input[CONF_PASSWORD], user_input[CONF_VIN], user_input[CONF_VCC_API_KEY]) - async def async_step_import(self, user_input): + async def async_step_import(self, user_input: dict) -> None: """Import a config entry.""" return await self._create_device(user_input[CONF_USERNAME], user_input[CONF_PASSWORD], user_input[CONF_VIN], user_input[CONF_VCC_API_KEY]) diff --git a/custom_components/polestar_api/const.py b/custom_components/polestar_api/const.py index ea20b05..8e60b2b 100644 --- a/custom_components/polestar_api/const.py +++ b/custom_components/polestar_api/const.py @@ -1,7 +1,6 @@ DOMAIN = "polestar_api" TIMEOUT = 90 -MAX_CHARGE_RANGE = 375 CONF_VIN = "vin" CONF_VCC_API_KEY = "vcc_api_key" @@ -12,3 +11,5 @@ HEADER_AUTHORIZATION = "authorization" HEADER_VCC_API_KEY = "vcc-api-key" + +CACHE_TIME = 15 diff --git a/custom_components/polestar_api/entity.py b/custom_components/polestar_api/entity.py index 5d6c485..694775a 100644 --- a/custom_components/polestar_api/entity.py +++ b/custom_components/polestar_api/entity.py @@ -1,21 +1,20 @@ -from datetime import timedelta import logging from .polestar import PolestarApi -from .const import DOMAIN as Tibber_EV_DOMAIN +from .const import DOMAIN as POLESTAR_API_DOMAIN from homeassistant.helpers.entity import DeviceInfo, Entity _LOGGER = logging.getLogger(__name__) -class TibberEVEntity(Entity): +class PolestarEntity(Entity): def __init__(self, device: PolestarApi) -> None: """Initialize the Polestar entity.""" self._device = device self._attr_device_info = DeviceInfo( - identifiers={(Tibber_EV_DOMAIN, self._device.name)}, + identifiers={(POLESTAR_API_DOMAIN, self._device.name)}, manufacturer="Polestar", model=None, name=device.name, diff --git a/custom_components/polestar_api/polestar.py b/custom_components/polestar_api/polestar.py index 43fa799..94f8e47 100644 --- a/custom_components/polestar_api/polestar.py +++ b/custom_components/polestar_api/polestar.py @@ -33,8 +33,8 @@ def __init__(self, self.polestar_api = polestar_api disable_warnings() - async def init(self): - self.id = "tibber_{}".format(self.name) + async def init(self) -> None: + self.id = "polestar{}".format(self.name) if self.name is None: self.name = f"{self.info.identity} ({self.host})" @@ -43,5 +43,5 @@ def status(self) -> str: return self._status @Throttle(timedelta(seconds=10)) - async def async_update(self): + async def async_update(self) -> None: self.raw_data = await self.polestar_api.get_ev_data() diff --git a/custom_components/polestar_api/polestar_api.py b/custom_components/polestar_api/polestar_api.py index a40d3bd..0dd6c43 100644 --- a/custom_components/polestar_api/polestar_api.py +++ b/custom_components/polestar_api/polestar_api.py @@ -4,6 +4,7 @@ from .const import ( ACCESS_TOKEN_MANAGER_ID, AUTHORIZATION, + CACHE_TIME, GRANT_TYPE, HEADER_AUTHORIZATION, HEADER_VCC_API_KEY @@ -22,8 +23,6 @@ class PolestarApi: - QUERY_PAYLOAD = "" - def __init__(self, hass: HomeAssistant, username: str, @@ -41,14 +40,13 @@ def __init__(self, self.refresh_token = None self.vin = vin self.vcc_api_key = vcc_api_key - # data and timestamp e.g. {'data': {}, 'timestamp': 1234567890} self.cache_data = None disable_warnings() async def init(self): await self.get_token() - async def get_token(self): + async def get_token(self) -> None: response = await self._session.post( url='https://volvoid.eu.volvocars.com/as/token.oauth2', data={ @@ -75,12 +73,12 @@ async def get_token(self): _LOGGER.debug(f"Response {self.access_token}") - def get_cache_data(self, path, reponse_path=None): + def get_cache_data(self, path: str, reponse_path: str = None) -> dict or bool or None: # replace the string {vin} with the actual vin path = path.replace('{vin}', self.vin) if self.cache_data and self.cache_data[path]: - if self.cache_data[path]['timestamp'] > datetime.now() - timedelta(seconds=15): + if self.cache_data[path]['timestamp'] > datetime.now() - timedelta(seconds=CACHE_TIME): data = self.cache_data[path]['data'] if data is None: return False @@ -89,7 +87,7 @@ def get_cache_data(self, path, reponse_path=None): data = data[key] return data - async def get_data(self, path, reponse_path=None): + async def get_data(self, path: str, reponse_path: str = None) -> dict or bool or None: path = path.replace('{vin}', self.vin) cache_data = self.get_cache_data(path, reponse_path) diff --git a/custom_components/polestar_api/sensor.py b/custom_components/polestar_api/sensor.py index f62242d..847d526 100644 --- a/custom_components/polestar_api/sensor.py +++ b/custom_components/polestar_api/sensor.py @@ -3,8 +3,7 @@ from typing import Final from dataclasses import dataclass -from .const import MAX_CHARGE_RANGE -from .entity import TibberEVEntity +from .entity import PolestarEntity from homeassistant.helpers.typing import StateType @@ -23,7 +22,7 @@ from homeassistant.helpers import entity_platform -from . import DOMAIN as TIBBER_EV_DOMAIN +from . import DOMAIN as POLESTAR_API_DOMAIN from .polestar import PolestarApi @@ -68,7 +67,7 @@ class PolestarSensorDescription( } -TIBBER_SENSOR_TYPES: Final[tuple[PolestarSensorDescription, ...]] = ( +POLESTAR_SENSOR_TYPES: Final[tuple[PolestarSensorDescription, ...]] = ( PolestarSensorDescription( key="battery_charge_level", name="Battery level", @@ -148,18 +147,18 @@ async def async_setup_entry( """Set up using config_entry.""" # get the device device: PolestarApi - device = hass.data[TIBBER_EV_DOMAIN][entry.entry_id] + device = hass.data[POLESTAR_API_DOMAIN][entry.entry_id] # put data in cache await device.get_data("{vin}/recharge-status") sensors = [ - PolestarSensor(device, description) for description in TIBBER_SENSOR_TYPES + PolestarSensor(device, description) for description in POLESTAR_SENSOR_TYPES ] async_add_entities(sensors) platform = entity_platform.current_platform.get() -class PolestarSensor(TibberEVEntity, SensorEntity): +class PolestarSensor(PolestarEntity, SensorEntity): """Representation of a Polestar Sensor.""" entity_description: PolestarSensorDescription @@ -223,24 +222,34 @@ def native_unit_of_measurement(self) -> str | None: @property def state(self) -> StateType: """Return the state of the sensor.""" + if self._attr_native_value is None: + return None + # parse the long text with a shorter one from the dict if self.entity_description.key == 'charging_connection_status': return ChargingConnectionStatusDict.get(self._attr_native_value, self._attr_native_value) if self.entity_description.key == 'charging_system_status': return ChargingSystemStatusDict.get(self._attr_native_value, self._attr_native_value) + # battery charge level contain ".0" at the end, this should be removed + if self.entity_description.key == 'battery_charge_level': + if isinstance(self._attr_native_value, str): + self._attr_native_value = int( + self._attr_native_value.replace('.0', '')) + # Custom state for estimated_fully_charged_time if self.entity_description.key == 'estimated_fully_charged_time': - if self._attr_native_value is not None: - value = int(self._attr_native_value) - if value > 0: - return datetime.now().replace(second=0, microsecond=0) + timedelta(minutes=round(value)) + value = int(self._attr_native_value) + if value > 0: + return datetime.now().replace(second=0, microsecond=0) + timedelta(minutes=round(value)) return 'Not charging' # round the value if self.entity_description.round_digits is not None: - if self._attr_native_value is not None: - return round(float(self._attr_native_value), self.entity_description.round_digits) + # if the value is integer, remove the decimal + if self.entity_description.round_digits == 0 and isinstance(self._attr_native_value, int): + return int(self._attr_native_value) + return round(float(self._attr_native_value), self.entity_description.round_digits) return self._attr_native_value @property @@ -248,9 +257,11 @@ def unit_of_measurement(self) -> str: """Return the unit of measurement.""" return self.entity_description.unit - async def async_update(self): + async def async_update(self) -> None: """Get the latest data and updates the states.""" data = await self._device.get_data(self.entity_description.path, self.entity_description.response_path) - if data is not None: - self._attr_native_value = data - self.value = data + if data is None: + return + + self._attr_native_value = data + self.value = data From c1d7d40a4ded7e3b5d28f1789aed195ec555b516 Mon Sep 17 00:00:00 2001 From: Tuen Lee Date: Sun, 19 Nov 2023 00:18:02 +0100 Subject: [PATCH 3/6] add last updated --- custom_components/polestar_api/sensor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/custom_components/polestar_api/sensor.py b/custom_components/polestar_api/sensor.py index 847d526..31b293d 100644 --- a/custom_components/polestar_api/sensor.py +++ b/custom_components/polestar_api/sensor.py @@ -78,6 +78,16 @@ class PolestarSensorDescription( state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.BATTERY, ), + PolestarSensorDescription( + key="last_updated", + name="Last updated", + path="{vin}/recharge-status", + response_path="batteryChargeLevel.timestamp", + unit=None, + round_digits=None, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TIMESTAMP, + ), PolestarSensorDescription( key="electric_range", name="EV Range", From b43908fe8d11e319ce032708be326ec804aae5ce Mon Sep 17 00:00:00 2001 From: Tuen Lee Date: Sun, 19 Nov 2023 00:34:52 +0100 Subject: [PATCH 4/6] change entity_id with polestar_unique_id --- custom_components/polestar_api/sensor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/polestar_api/sensor.py b/custom_components/polestar_api/sensor.py index 31b293d..f480901 100644 --- a/custom_components/polestar_api/sensor.py +++ b/custom_components/polestar_api/sensor.py @@ -179,10 +179,11 @@ def __init__(self, """Initialize the sensor.""" super().__init__(device) self._device = device - # get the first 8 character of the id - unique_id = device.vin[:8] + # get the last 4 character of the id + unique_id = device.vin[-4:] + self.entity_id = f"{POLESTAR_API_DOMAIN}.'polestar_'.{unique_id}_{description.key}" self._attr_name = f"{description.name}" - self._attr_unique_id = f"{unique_id}-{description.key}" + self._attr_unique_id = f"polestar_{unique_id}-{description.key}" self.value = None self.description = description From 0c46233209d9943dad300b0c1806937a8377d0bd Mon Sep 17 00:00:00 2001 From: Tuen Lee Date: Sun, 19 Nov 2023 11:44:37 +0100 Subject: [PATCH 5/6] fix #2 prevent spike value --- custom_components/polestar_api/sensor.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/custom_components/polestar_api/sensor.py b/custom_components/polestar_api/sensor.py index f480901..214190c 100644 --- a/custom_components/polestar_api/sensor.py +++ b/custom_components/polestar_api/sensor.py @@ -45,6 +45,7 @@ class PolestarSensorDescriptionMixin: round_digits: int | None unit: str | None response_path: str | None + max_value: int | None @dataclass @@ -77,6 +78,7 @@ class PolestarSensorDescription( round_digits=0, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.BATTERY, + max_value=None, ), PolestarSensorDescription( key="last_updated", @@ -87,6 +89,7 @@ class PolestarSensorDescription( round_digits=None, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TIMESTAMP, + max_value=None, ), PolestarSensorDescription( key="electric_range", @@ -98,6 +101,7 @@ class PolestarSensorDescription( round_digits=None, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DISTANCE, + max_value=570, # prevent spike value, and this should be the max range of polestar ), PolestarSensorDescription( key="estimated_charging_time", @@ -107,6 +111,7 @@ class PolestarSensorDescription( response_path="estimatedChargingTime.value", unit='Minutes', round_digits=None, + max_value=None, ), PolestarSensorDescription( key="estimated_fully_charged_time", @@ -118,6 +123,7 @@ class PolestarSensorDescription( round_digits=None, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DURATION, + max_value=None, ), PolestarSensorDescription( key="charging_connection_status", @@ -128,6 +134,7 @@ class PolestarSensorDescription( unit=None, round_digits=None, state_class=SensorStateClass.MEASUREMENT, + max_value=None, ), PolestarSensorDescription( key="charging_system_status", @@ -137,6 +144,7 @@ class PolestarSensorDescription( response_path="chargingSystemStatus.value", unit=None, round_digits=None, + max_value=None, ), ) @@ -248,6 +256,13 @@ def state(self) -> StateType: self._attr_native_value = int( self._attr_native_value.replace('.0', '')) + # prevent exponentianal value, we only give state value that is lower than the max value + if self.entity_description.max_value is not None: + if isinstance(self._attr_native_value, str): + self._attr_native_value = int(self._attr_native_value) + if self._attr_native_value > self.entity_description.max_value: + return None + # Custom state for estimated_fully_charged_time if self.entity_description.key == 'estimated_fully_charged_time': value = int(self._attr_native_value) From fe13a1b8bca6dbcb044d17d3dca9a6dad5eb4ee7 Mon Sep 17 00:00:00 2001 From: Tuen Lee Date: Sun, 19 Nov 2023 17:17:34 +0100 Subject: [PATCH 6/6] add extra entity: expected full charge range fix #4 --- custom_components/polestar_api/sensor.py | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/custom_components/polestar_api/sensor.py b/custom_components/polestar_api/sensor.py index 214190c..4add5d9 100644 --- a/custom_components/polestar_api/sensor.py +++ b/custom_components/polestar_api/sensor.py @@ -69,6 +69,18 @@ class PolestarSensorDescription( POLESTAR_SENSOR_TYPES: Final[tuple[PolestarSensorDescription, ...]] = ( + PolestarSensorDescription( + key="estimate_full_charge_range", + name="Est. full charge range", + icon="mdi:map-marker-distance", + path="{vin}/recharge-status", + response_path=None, + unit='km', + round_digits=None, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + max_value=None + ), PolestarSensorDescription( key="battery_charge_level", name="Battery level", @@ -276,6 +288,24 @@ def state(self) -> StateType: if self.entity_description.round_digits == 0 and isinstance(self._attr_native_value, int): return int(self._attr_native_value) return round(float(self._attr_native_value), self.entity_description.round_digits) + + if self.entity_description.key == 'estimate_full_charge_range': + battery_level = self._device.get_cache_data( + self.entity_description.path, 'batteryChargeLevel.value') + estimate_range = self._device.get_cache_data( + self.entity_description.path, 'electricRange.value') + + if battery_level is None or estimate_range is None: + return None + + if battery_level is False or estimate_range is False: + return None + + battery_level = int(battery_level.replace('.0', '')) + estimate_range = int(estimate_range) + + return round(estimate_range / battery_level * 100) + return self._attr_native_value @property