From 4d339ec53f6294905c14b3b8c2c8f22e61422cd2 Mon Sep 17 00:00:00 2001 From: Tuen Lee Date: Sun, 19 Nov 2023 22:49:29 +0100 Subject: [PATCH 1/6] add miles on entities #8 --- custom_components/polestar_api/const.py | 2 +- .../polestar_api/polestar_api.py | 18 ++++++- custom_components/polestar_api/sensor.py | 49 +++++++++++++++++-- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/custom_components/polestar_api/const.py b/custom_components/polestar_api/const.py index 8e60b2b..dc41230 100644 --- a/custom_components/polestar_api/const.py +++ b/custom_components/polestar_api/const.py @@ -12,4 +12,4 @@ HEADER_AUTHORIZATION = "authorization" HEADER_VCC_API_KEY = "vcc-api-key" -CACHE_TIME = 15 +CACHE_TIME = 10 diff --git a/custom_components/polestar_api/polestar_api.py b/custom_components/polestar_api/polestar_api.py index 0dd6c43..e47f56d 100644 --- a/custom_components/polestar_api/polestar_api.py +++ b/custom_components/polestar_api/polestar_api.py @@ -73,6 +73,20 @@ async def get_token(self) -> None: _LOGGER.debug(f"Response {self.access_token}") + def get_latest_data(self, path: str, reponse_path: str = None) -> dict or bool or None: + # i don't care what cache is, just give me the latest data in the cache + # replace the string {vin} with the actual vin + path = path.replace('{vin}', self.vin) + + if self.cache_data and self.cache_data[path]: + data = self.cache_data[path]['data'] + if data is None: + return False + if reponse_path: + for key in reponse_path.split('.'): + data = data[key] + return data + 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) @@ -101,7 +115,9 @@ async def get_data(self, path: str, reponse_path: str = None) -> dict or bool or # put as fast possible something in the cache otherwise we get a lot of requests if not self.cache_data: self.cache_data = {} - self.cache_data[path] = {'data': None, 'timestamp': datetime.now()} + self.cache_data[path] = {'data': None, 'timestamp': datetime.now()} + else: + self.cache_data[path]['timestamp'] = datetime.now() url = 'https://api.volvocars.com/energy/v1/vehicles/' + path headers = { diff --git a/custom_components/polestar_api/sensor.py b/custom_components/polestar_api/sensor.py index 4add5d9..59447d4 100644 --- a/custom_components/polestar_api/sensor.py +++ b/custom_components/polestar_api/sensor.py @@ -81,6 +81,18 @@ class PolestarSensorDescription( device_class=SensorDeviceClass.DISTANCE, max_value=None ), + PolestarSensorDescription( + key="estimate_full_charge_range_miles", + name="Est. full charge range", + icon="mdi:map-marker-distance", + path="{vin}/recharge-status", + response_path=None, + unit='miles', + round_digits=None, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + max_value=None + ), PolestarSensorDescription( key="battery_charge_level", name="Battery level", @@ -115,6 +127,18 @@ class PolestarSensorDescription( device_class=SensorDeviceClass.DISTANCE, max_value=570, # prevent spike value, and this should be the max range of polestar ), + PolestarSensorDescription( + key="electric_range_miles", + name="EV Range", + icon="mdi:map-marker-distance", + path="{vin}/recharge-status", + response_path="electricRange.value", + unit='miles', + round_digits=None, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + max_value=355, # prevent spike value, and this should be the max range of polestar + ), PolestarSensorDescription( key="estimated_charging_time", name="Charging time", @@ -289,10 +313,10 @@ def state(self) -> StateType: 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( + if self.entity_description.key in ('estimate_full_charge_range', 'estimate_full_charge_range_miles'): + battery_level = self._device.get_latest_data( self.entity_description.path, 'batteryChargeLevel.value') - estimate_range = self._device.get_cache_data( + estimate_range = self._device.get_latest_data( self.entity_description.path, 'electricRange.value') if battery_level is None or estimate_range is None: @@ -304,8 +328,25 @@ def state(self) -> StateType: battery_level = int(battery_level.replace('.0', '')) estimate_range = int(estimate_range) - return round(estimate_range / battery_level * 100) + estimate_range = round(estimate_range / battery_level * 100) + + if self.entity_description.key == 'estimate_full_charge_range_miles': + return round(estimate_range / 1.609344, self.entity_description.round_digits if self.entity_description.round_digits is not None else 0) + + return estimate_range + + if self.entity_description.key == 'electric_range_miles': + if self._attr_native_value is None: + return None + + if self._attr_native_value is False: + return None + + self._attr_native_value = int(self._attr_native_value) + miles = round(self._attr_native_value / 1.609344, + self.entity_description.round_digits if self.entity_description.round_digits is not None else 0) + return miles return self._attr_native_value @property From 996518a8edb07b9f43c20b14247d3941770a6957 Mon Sep 17 00:00:00 2001 From: Tuen Lee Date: Mon, 20 Nov 2023 11:01:32 +0100 Subject: [PATCH 2/6] add system status and connection status --- custom_components/polestar_api/sensor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/custom_components/polestar_api/sensor.py b/custom_components/polestar_api/sensor.py index 59447d4..e98e1c2 100644 --- a/custom_components/polestar_api/sensor.py +++ b/custom_components/polestar_api/sensor.py @@ -57,14 +57,16 @@ class PolestarSensorDescription( ChargingConnectionStatusDict = { "CONNECTION_STATUS_DISCONNECTED": "Disconnected", - "CONNECTION_STATUS_CONNECTED": "Connected", "CONNECTION_STATUS_CONNECTED_AC": "Connected AC", + "CONNECTION_STATUS_CONNECTED_DC": "Connected DC", + "CONNECTION_STATUS_UNSPECIFIED": "Unspecified", } ChargingSystemStatusDict = { "CHARGING_SYSTEM_UNSPECIFIED": "Unspecified", "CHARGING_SYSTEM_CHARGING": "Charging", "CHARGING_SYSTEM_IDLE": "Idle", + "CHARGING_SYSTEM_FAULT": "Fault", } From 01aab31a6606ed8ae1b9c606a2f98bd3552a57cc Mon Sep 17 00:00:00 2001 From: Tuen Lee Date: Mon, 27 Nov 2023 23:19:24 +0100 Subject: [PATCH 3/6] add status code to know if the api is up or down --- custom_components/polestar_api/config_flow.py | 7 +++++ .../polestar_api/polestar_api.py | 7 +++++ custom_components/polestar_api/sensor.py | 28 ++++++++++++++----- custom_components/polestar_api/strings.json | 3 +- .../polestar_api/translations/en.json | 3 +- 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/custom_components/polestar_api/config_flow.py b/custom_components/polestar_api/config_flow.py index 978c2eb..a576a92 100644 --- a/custom_components/polestar_api/config_flow.py +++ b/custom_components/polestar_api/config_flow.py @@ -48,6 +48,13 @@ async def _create_device(self, username: str, password: str, vin: str, vcc_api_k ) with timeout(TIMEOUT): await device.init() + + # check if we have a token, otherwise throw exception + if device.access_token is None: + _LOGGER.exception( + "No token, Could be wrong credentials (invalid email or password))") + return self.async_abort(reason="no_token") + except asyncio.TimeoutError: return self.async_abort(reason="api_timeout") except ClientError: diff --git a/custom_components/polestar_api/polestar_api.py b/custom_components/polestar_api/polestar_api.py index e47f56d..29912cc 100644 --- a/custom_components/polestar_api/polestar_api.py +++ b/custom_components/polestar_api/polestar_api.py @@ -41,6 +41,7 @@ def __init__(self, self.vin = vin self.vcc_api_key = vcc_api_key self.cache_data = None + self.latest_call_code = None disable_warnings() async def init(self): @@ -89,6 +90,8 @@ def get_latest_data(self, path: str, reponse_path: str = None) -> dict or bool o def get_cache_data(self, path: str, reponse_path: str = None) -> dict or bool or None: # replace the string {vin} with the actual vin + if path is None: + return None path = path.replace('{vin}', self.vin) if self.cache_data and self.cache_data[path]: @@ -102,6 +105,9 @@ def get_cache_data(self, path: str, reponse_path: str = None) -> dict or bool or return data async def get_data(self, path: str, reponse_path: str = None) -> dict or bool or None: + if path is None: + return None + path = path.replace('{vin}', self.vin) cache_data = self.get_cache_data(path, reponse_path) @@ -130,6 +136,7 @@ async def get_data(self, path: str, reponse_path: str = None) -> dict or bool or headers=headers ) _LOGGER.debug(f"Response {response}") + self.latest_call_code = response.status if response.status == 401: await self.get_token() return diff --git a/custom_components/polestar_api/sensor.py b/custom_components/polestar_api/sensor.py index e98e1c2..9721442 100644 --- a/custom_components/polestar_api/sensor.py +++ b/custom_components/polestar_api/sensor.py @@ -69,6 +69,12 @@ class PolestarSensorDescription( "CHARGING_SYSTEM_FAULT": "Fault", } +API_STATUS_DICT = { + 200: "OK", + 401: "Unauthorized", + 404: "API Down" +} + POLESTAR_SENSOR_TYPES: Final[tuple[PolestarSensorDescription, ...]] = ( PolestarSensorDescription( @@ -184,6 +190,16 @@ class PolestarSensorDescription( round_digits=None, max_value=None, ), + PolestarSensorDescription( + key="api_status_code", + name="API status", + icon="mdi:heart", + path=None, + response_path=None, + unit=None, + round_digits=None, + max_value=None, + ), ) @@ -279,7 +295,11 @@ 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: + + if self.entity_description.key == 'api_status_code': + return API_STATUS_DICT.get(self._device.latest_call_code, "Error") + + if self._attr_native_value in (None, False): return None # parse the long text with a shorter one from the dict @@ -338,12 +358,6 @@ def state(self) -> StateType: return estimate_range if self.entity_description.key == 'electric_range_miles': - if self._attr_native_value is None: - return None - - if self._attr_native_value is False: - return None - self._attr_native_value = int(self._attr_native_value) miles = round(self._attr_native_value / 1.609344, self.entity_description.round_digits if self.entity_description.round_digits is not None else 0) diff --git a/custom_components/polestar_api/strings.json b/custom_components/polestar_api/strings.json index a46f505..669f8e3 100644 --- a/custom_components/polestar_api/strings.json +++ b/custom_components/polestar_api/strings.json @@ -16,7 +16,8 @@ "abort": { "api_timeout": "Timeout connecting to the api.", "api_failed": "Unexpected error creating api.", - "already_configured": "Polestar API is already configured" + "already_configured": "Polestar API is already configured", + "no_token": "No token found in response. Please check your credentials." } } } diff --git a/custom_components/polestar_api/translations/en.json b/custom_components/polestar_api/translations/en.json index a46f505..669f8e3 100644 --- a/custom_components/polestar_api/translations/en.json +++ b/custom_components/polestar_api/translations/en.json @@ -16,7 +16,8 @@ "abort": { "api_timeout": "Timeout connecting to the api.", "api_failed": "Unexpected error creating api.", - "already_configured": "Polestar API is already configured" + "already_configured": "Polestar API is already configured", + "no_token": "No token found in response. Please check your credentials." } } } From f478e1749341b690e41af905f21af9c692ceb05e Mon Sep 17 00:00:00 2001 From: Tuen Lee Date: Tue, 19 Dec 2023 12:31:32 +0100 Subject: [PATCH 4/6] rework Alpha 1.0 --- custom_components/polestar_api/__init__.py | 4 +- custom_components/polestar_api/config_flow.py | 20 +- custom_components/polestar_api/const.py | 4 +- .../polestar_api/polestar_api.py | 259 +++++++++++------ custom_components/polestar_api/sensor.py | 273 +++++++++++++----- custom_components/polestar_api/strings.json | 3 +- .../polestar_api/translations/en.json | 3 +- 7 files changed, 374 insertions(+), 192 deletions(-) diff --git a/custom_components/polestar_api/__init__.py b/custom_components/polestar_api/__init__.py index ef00591..66154ea 100644 --- a/custom_components/polestar_api/__init__.py +++ b/custom_components/polestar_api/__init__.py @@ -6,6 +6,7 @@ from aiohttp import ClientConnectionError from async_timeout import timeout + from .polestar_api import PolestarApi from homeassistant.config_entries import ConfigEntry @@ -20,7 +21,6 @@ from .const import ( CONF_VIN, - CONF_VCC_API_KEY, DOMAIN, TIMEOUT ) @@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.debug("async_setup_entry: %s", config_entry) polestarApi = PolestarApi( - hass, conf[CONF_USERNAME], conf[CONF_PASSWORD], conf[CONF_VIN], conf[CONF_VCC_API_KEY]) + hass, conf[CONF_USERNAME], conf[CONF_PASSWORD], conf[CONF_VIN]) await polestarApi.init() hass.data.setdefault(DOMAIN, {}) diff --git a/custom_components/polestar_api/config_flow.py b/custom_components/polestar_api/config_flow.py index a576a92..a4e0c68 100644 --- a/custom_components/polestar_api/config_flow.py +++ b/custom_components/polestar_api/config_flow.py @@ -11,7 +11,7 @@ from .polestar import PolestarApi -from .const import CONF_VIN, CONF_VCC_API_KEY, DOMAIN, TIMEOUT +from .const import CONF_VIN, DOMAIN, TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -23,7 +23,7 @@ class FlowHandler(config_entries.ConfigFlow): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - async def _create_entry(self, username: str, password: str, vin: str, vcc_api_key: str) -> None: + async def _create_entry(self, username: str, password: str, vin: str) -> None: """Register new entry.""" return self.async_create_entry( title='Polestar EV', @@ -31,11 +31,10 @@ async def _create_entry(self, username: str, password: str, vin: str, vcc_api_ke CONF_USERNAME: username, CONF_PASSWORD: password, CONF_VIN: vin, - CONF_VCC_API_KEY: vcc_api_key } ) - async def _create_device(self, username: str, password: str, vin: str, vcc_api_key: str) -> None: + async def _create_device(self, username: str, password: str, vin: str) -> None: """Create device.""" try: @@ -43,9 +42,7 @@ async def _create_device(self, username: str, password: str, vin: str, vcc_api_k self.hass, username, password, - vin, - vcc_api_key - ) + vin) with timeout(TIMEOUT): await device.init() @@ -64,7 +61,7 @@ async def _create_device(self, username: str, password: str, vin: str, vcc_api_k _LOGGER.exception("Unexpected error creating device") return self.async_abort(reason="api_failed") - return await self._create_entry(username, password, vin, vcc_api_key) + return await self._create_entry(username, password, vin) async def async_step_user(self, user_input: dict = None) -> None: """User initiated config flow.""" @@ -73,12 +70,11 @@ async def async_step_user(self, user_input: dict = None) -> None: step_id="user", data_schema=vol.Schema({ vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_VIN): str, - vol.Required(CONF_VCC_API_KEY): str, + vol.Required(CONF_VIN): str }) ) - return await self._create_device(user_input[CONF_USERNAME], user_input[CONF_PASSWORD], user_input[CONF_VIN], user_input[CONF_VCC_API_KEY]) + return await self._create_device(user_input[CONF_USERNAME], user_input[CONF_PASSWORD], user_input[CONF_VIN]) 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]) + return await self._create_device(user_input[CONF_USERNAME], user_input[CONF_PASSWORD], user_input[CONF_VIN]) diff --git a/custom_components/polestar_api/const.py b/custom_components/polestar_api/const.py index dc41230..42d0bc7 100644 --- a/custom_components/polestar_api/const.py +++ b/custom_components/polestar_api/const.py @@ -3,13 +3,11 @@ CONF_VIN = "vin" -CONF_VCC_API_KEY = "vcc_api_key" ACCESS_TOKEN_MANAGER_ID = "JWTh4Yf0b" GRANT_TYPE = "password" AUTHORIZATION = "Basic aDRZZjBiOlU4WWtTYlZsNnh3c2c1WVFxWmZyZ1ZtSWFEcGhPc3kxUENhVXNpY1F0bzNUUjVrd2FKc2U0QVpkZ2ZJZmNMeXc=" HEADER_AUTHORIZATION = "authorization" -HEADER_VCC_API_KEY = "vcc-api-key" -CACHE_TIME = 10 +CACHE_TIME = 30 diff --git a/custom_components/polestar_api/polestar_api.py b/custom_components/polestar_api/polestar_api.py index 29912cc..78e7c5f 100644 --- a/custom_components/polestar_api/polestar_api.py +++ b/custom_components/polestar_api/polestar_api.py @@ -1,13 +1,13 @@ from datetime import datetime, timedelta import json import logging + from .const import ( ACCESS_TOKEN_MANAGER_ID, AUTHORIZATION, CACHE_TIME, GRANT_TYPE, HEADER_AUTHORIZATION, - HEADER_VCC_API_KEY ) from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -16,7 +16,6 @@ from homeassistant.core import HomeAssistant - POST_HEADER_JSON = {"Content-Type": "application/json"} _LOGGER = logging.getLogger(__name__) @@ -28,7 +27,6 @@ def __init__(self, username: str, password: str, vin: str, - vcc_api_key: str, ) -> None: self.id = vin[:8] self.name = "Polestar " + vin[-4:] @@ -39,38 +37,95 @@ def __init__(self, self.token_type = None self.refresh_token = None self.vin = vin - self.vcc_api_key = vcc_api_key - self.cache_data = None + self.cache_data = {} self.latest_call_code = None + self.updating = False disable_warnings() async def init(self): await self.get_token() + result = await self.get_vehicle_data() + self.cache_data['getConsumerCarsV2'] = { + 'data': result['data']['getConsumerCarsV2'][0], 'timestamp': datetime.now()} + + async def _get_resume_path(self): + # Get Resume Path + params = { + "response_type": "code", + "client_id": "polmystar", + "redirect_uri": "https://www.polestar.com/sign-in-callback" + } + result = await self._session.get("https://polestarid.eu.polestar.com/as/authorization.oauth2", params=params) + if result.status != 200: + _LOGGER.error(f"Error getting resume path {result.status}") + return + return result.real_url.query_string + + async def _get_code(self) -> None: + resumePath = await self._get_resume_path() + + # if resumepath has code, then we don't need to login + if 'code' in resumePath: + return resumePath.replace('code=', '') + + # if resumepath has resumepat + + # get the realUrl + resumePath = resumePath.replace( + 'resumePath=', '').replace('&client_id=polmystar', '') + + if resumePath is None: + return + + params = { + 'client_id': 'polmystar' + } + data = { + 'pf.username': self.username, + 'pf.pass': self.password + } + result = await self._session.post(f"https://polestarid.eu.polestar.com/as/{resumePath}/resume/as/authorization.ping", params=params, data=data) + if result.status != 200: + _LOGGER.error(f"Error getting code {result.status}") + return + # get the realUrl + url = result.url + code = result.url.query_string.replace('code=', '') + + # sign-in-callback + result = await self._session.get("https://www.polestar.com/sign-in-callback?code=" + code) + if result.status != 200: + _LOGGER.error(f"Error getting code callback {result.status}") + return + # url encode the code + result = await self._session.get(url) + + return code async def get_token(self) -> None: - response = await self._session.post( - url='https://volvoid.eu.volvocars.com/as/token.oauth2', - data={ - 'username': self.username, - 'password': self.password, - 'grant_type': GRANT_TYPE, - 'access_token_manager_id': ACCESS_TOKEN_MANAGER_ID, - 'scope': 'openid email profile care_by_volvo:financial_information:invoice:read care_by_volvo:financial_information:payment_method care_by_volvo:subscription:read customer:attributes customer:attributes:write order:attributes vehicle:attributes tsp_customer_api:all conve:brake_status conve:climatization_start_stop conve:command_accessibility conve:commands conve:diagnostics_engine_status conve:diagnostics_workshop conve:doors_status conve:engine_status conve:environment conve:fuel_status conve:honk_flash conve:lock conve:lock_status conve:navigation conve:odometer_status conve:trip_statistics conve:tyre_status conve:unlock conve:vehicle_relation conve:warnings conve:windows_status energy:battery_charge_level energy:charging_connection_status energy:charging_system_status energy:electric_range energy:estimated_charging_time energy:recharge_status' - }, - headers={ - HEADER_AUTHORIZATION: AUTHORIZATION, - 'content-type': 'application/x-www-form-urlencoded', - 'user-agent': 'okhttp/4.10.0' - }, - ) - _LOGGER.debug(f"Response {response}") - if response.status != 200: - _LOGGER.info("Info API not available") + code = await self._get_code() + if code is None: return - resp = await response.json(content_type=None) - self.access_token = resp['access_token'] - self.refresh_token = resp['refresh_token'] - self.token_type = resp['token_type'] + + # get token + params = { + "query": "query getAuthToken($code: String!) { getAuthToken(code: $code) { id_token access_token refresh_token expires_in }}", + "operationName": "getAuthToken", + "variables": "{\"code\":\"" + code + "\"}" + } + headers = { + "Content-Type": "application/json" + } + result = await self._session.get("https://pc-api.polestar.com/eu-north-1/auth/", params=params, headers=headers) + if result.status != 200: + _LOGGER.error(f"Error getting code {result.status}") + return + resultData = await result.json() + _LOGGER.debug(resultData) + + self.access_token = resultData['data']['getAuthToken']['access_token'] + self.refresh_token = resultData['data']['getAuthToken']['refresh_token'] + # ID Token _LOGGER.debug(f"Response {self.access_token}") @@ -88,72 +143,94 @@ def get_latest_data(self, path: str, reponse_path: str = None) -> dict or bool o data = data[key] return data - def get_cache_data(self, path: str, reponse_path: str = None) -> dict or bool or None: - # replace the string {vin} with the actual vin - if path is None: - return None - 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=CACHE_TIME): - data = self.cache_data[path]['data'] - if data is None: - return False - if reponse_path: - for key in reponse_path.split('.'): - data = data[key] + def _get_field_name_value(self, field_name: str, data: dict) -> str or bool or None: + if '/' in field_name: + field_name = field_name.split('/') + if data: + if isinstance(field_name, list): + for key in field_name: + data = data[key] return data - - async def get_data(self, path: str, reponse_path: str = None) -> dict or bool or None: - if path is None: - return None - - path = path.replace('{vin}', self.vin) - - cache_data = self.get_cache_data(path, reponse_path) - # if false, then we are fetching data just return - if cache_data is False: + return data[field_name] + return None + + def get_cache_data(self, query: str, field_name: str, skip_cache: bool = False): + if self.cache_data and self.cache_data[query]: + if skip_cache is False: + if self.cache_data[query]['timestamp'] + timedelta(seconds=CACHE_TIME) > datetime.now(): + data = self.cache_data[query]['data'] + if data is None: + return None + return self._get_field_name_value(field_name, data) + else: + data = self.cache_data[query]['data'] + if data is None: + return None + return self._get_field_name_value(field_name, data) + return None + + async def getOdometerData(self): + result = await self.get_odo_data() + # put result in cache + self.cache_data['getOdometerData'] = { + 'data': result['data']['getOdometerData'], 'timestamp': datetime.now()} + + async def getBatteryData(self): + result = await self.get_battery_data() + # put result in cache + self.cache_data['getBatteryData'] = { + 'data': result['data']['getBatteryData'], 'timestamp': datetime.now()} + + async def get_ev_data(self): + if self.updating is True: return - if cache_data: - _LOGGER.debug("Using cached data") - return cache_data - - # put as fast possible something in the cache otherwise we get a lot of requests - if not self.cache_data: - self.cache_data = {} - self.cache_data[path] = {'data': None, 'timestamp': datetime.now()} - else: - self.cache_data[path]['timestamp'] = datetime.now() - - url = 'https://api.volvocars.com/energy/v1/vehicles/' + path + self.updating = True + await self.getOdometerData() + await self.getBatteryData() + self.updating = False + + async def get_graph_ql(self, params: dict): headers = { - HEADER_AUTHORIZATION: f'{self.token_type} {self.access_token}', - HEADER_VCC_API_KEY: self.vcc_api_key + "Content-Type": "application/json", + "authorization": "Bearer " + self.access_token } - response = await self._session.get( - url=url, - headers=headers - ) - _LOGGER.debug(f"Response {response}") - self.latest_call_code = response.status - if response.status == 401: - await self.get_token() - return - if response.status != 200: - _LOGGER.debug("Info API not available") - return - resp = await response.json(content_type=None) - - _LOGGER.debug(f"Response {resp}") - - data = resp['data'] - - # add cache_data[path] - self.cache_data[path] = {'data': data, 'timestamp': datetime.now()} - - if reponse_path: - for key in reponse_path.split('.'): - data = data[key] - - return data + result = await self._session.get("https://pc-api.polestar.com/eu-north-1/my-star/", params=params, headers=headers) + resultData = await result.json() + + # if auth error, get new token + if resultData.get('errors'): + if resultData['errors'][0]['message'] == 'User not authenticated': + await self.get_token() + resultData = await self.get_graph_ql(params) + # log the error + _LOGGER.info(resultData.get('errors')) + _LOGGER.debug(resultData) + return resultData + + async def get_odo_data(self): + # get Odo Data + params = { + "query": "query GetOdometerData($vin: String!) { getOdometerData(vin: $vin) { averageSpeedKmPerHour eventUpdatedTimestamp { iso unix __typename } odometerMeters tripMeterAutomaticKm tripMeterManualKm __typename }}", + "operationName": "GetOdometerData", + "variables": "{\"vin\":\"" + self.vin + "\"}" + } + return await self.get_graph_ql(params) + + async def get_battery_data(self): + # get Battery Data + params = { + "query": "query GetBatteryData($vin: String!) { getBatteryData(vin: $vin) { averageEnergyConsumptionKwhPer100Km batteryChargeLevelPercentage chargerConnectionStatus chargingCurrentAmps chargingPowerWatts chargingStatus estimatedChargingTimeMinutesToTargetDistance estimatedChargingTimeToFullMinutes estimatedDistanceToEmptyKm estimatedDistanceToEmptyMiles eventUpdatedTimestamp { iso unix __typename } __typename }}", + "operationName": "GetBatteryData", + "variables": "{\"vin\":\"" + self.vin + "\"}" + } + return await self.get_graph_ql(params) + + async def get_vehicle_data(self): + # get Vehicle Data + params = { + "query": "query getCars { getConsumerCarsV2 { vin internalVehicleIdentifier modelYear content { model { code name __typename } images { studio { url angles __typename } __typename } __typename } hasPerformancePackage registrationNo deliveryDate currentPlannedDeliveryDate __typename }}", + "operationName": "getCars", + "variables": "{}" + } + return await self.get_graph_ql(params) diff --git a/custom_components/polestar_api/sensor.py b/custom_components/polestar_api/sensor.py index 9721442..711be05 100644 --- a/custom_components/polestar_api/sensor.py +++ b/custom_components/polestar_api/sensor.py @@ -40,11 +40,10 @@ class PolestarSensorDescriptionMixin: """Define an entity description mixin for sensor entities.""" - path: str - unit: str + query: str + field_name: str round_digits: int | None unit: str | None - response_path: str | None max_value: int | None @@ -56,17 +55,18 @@ class PolestarSensorDescription( ChargingConnectionStatusDict = { - "CONNECTION_STATUS_DISCONNECTED": "Disconnected", - "CONNECTION_STATUS_CONNECTED_AC": "Connected AC", - "CONNECTION_STATUS_CONNECTED_DC": "Connected DC", - "CONNECTION_STATUS_UNSPECIFIED": "Unspecified", + "CHARGER_CONNECTION_STATUS_CONNECTED": "Connected", + "CHARGER_CONNECTION_STATUS_DISCONNECTED": "Disconnected", } ChargingSystemStatusDict = { - "CHARGING_SYSTEM_UNSPECIFIED": "Unspecified", - "CHARGING_SYSTEM_CHARGING": "Charging", - "CHARGING_SYSTEM_IDLE": "Idle", - "CHARGING_SYSTEM_FAULT": "Fault", + "CHARGING_STATUS_DONE": "Done", + "CHARGING_STATUS_IDLE": "Idle", + "CHARGING_STATUS_CHARGING": "Charging", + "CHARGING_STATUS_FAULT": "Fault", + "CHARGING_STATUS_UNSPECIFIED": "Unspecified", + "CHARGING_STATUS_SCHEDULED": "Scheduled", + } API_STATUS_DICT = { @@ -78,126 +78,218 @@ class PolestarSensorDescription( POLESTAR_SENSOR_TYPES: Final[tuple[PolestarSensorDescription, ...]] = ( PolestarSensorDescription( - key="estimate_full_charge_range", - name="Est. full charge range", + key="estimate_distance_to_empty_miles", + name="Est. distance miles", icon="mdi:map-marker-distance", - path="{vin}/recharge-status", - response_path=None, - unit='km', + query="getBatteryData", + field_name="estimatedDistanceToEmptyMiles", + unit='miles', round_digits=None, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DISTANCE, max_value=None ), PolestarSensorDescription( - key="estimate_full_charge_range_miles", - name="Est. full charge range", + key="estimate_distance_to_empty_km", + name="Est. distance km", icon="mdi:map-marker-distance", - path="{vin}/recharge-status", - response_path=None, - unit='miles', + query="getBatteryData", + field_name="estimatedDistanceToEmptyKm", + unit='km', round_digits=None, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DISTANCE, max_value=None ), PolestarSensorDescription( - key="battery_charge_level", - name="Battery level", - path="{vin}/recharge-status", - response_path="batteryChargeLevel.value", - unit=PERCENTAGE, - round_digits=0, + key="current_odometer_meters", + name="Odometer Meter", + icon="mdi:map-marker-distance", + query="getOdometerData", + field_name="odometerMeters", + unit='km', + round_digits=None, state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.BATTERY, - max_value=None, + device_class=SensorDeviceClass.DISTANCE, + max_value=None ), PolestarSensorDescription( - key="last_updated", - name="Last updated", - path="{vin}/recharge-status", - response_path="batteryChargeLevel.timestamp", - unit=None, + key="average_speed_km_per_hour", + name="Average Speed Per Hour", + icon="mdi:map-marker-distance", + query="getOdometerData", + field_name="averageSpeedKmPerHour", + unit='km', round_digits=None, state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.TIMESTAMP, - max_value=None, + device_class=SensorDeviceClass.DISTANCE, + max_value=None ), PolestarSensorDescription( - key="electric_range", - name="EV Range", + key="current_trip_meter_automatic", + name="Trip Meter Automatic", icon="mdi:map-marker-distance", - path="{vin}/recharge-status", - response_path="electricRange.value", + query="getOdometerData", + field_name="tripMeterAutomaticKm", unit='km', 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 + max_value=None ), PolestarSensorDescription( - key="electric_range_miles", - name="EV Range", + key="current_trip_meter_manual", + name="Trip Meter Manual", icon="mdi:map-marker-distance", - path="{vin}/recharge-status", - response_path="electricRange.value", - unit='miles', + query="getOdometerData", + field_name="tripMeterManualKm", + unit='km', round_digits=None, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DISTANCE, - max_value=355, # prevent spike value, and this should be the max range of polestar + max_value=None ), PolestarSensorDescription( - key="estimated_charging_time", + key="battery_charge_level", + name="Battery level", + query="getBatteryData", + field_name="batteryChargeLevelPercentage", + unit=PERCENTAGE, + round_digits=0, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + max_value=None, + ), + PolestarSensorDescription( + key="estimated_charging_time_to_full_minutes", name="Charging time", icon="mdi:battery-clock", - path="{vin}/recharge-status", - response_path="estimatedChargingTime.value", + query="getBatteryData", + field_name="estimatedChargingTimeToFullMinutes", unit='Minutes', round_digits=None, max_value=None, ), PolestarSensorDescription( - key="estimated_fully_charged_time", - name="Fully charged time", + key="charging_status", + name="Charging status", icon="mdi:battery-clock", - path="{vin}/recharge-status", - response_path="estimatedChargingTime.value", + query="getBatteryData", + field_name="chargingStatus", unit=None, round_digits=None, + max_value=None, + ), + PolestarSensorDescription( + key="charging_power_watts", + name="Charging Watt", + icon="mdi:lightning-bolt", + query="getBatteryData", + field_name="chargingPowerWatts", + unit='W', + round_digits=None, + max_value=None, state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.DURATION, + device_class=SensorDeviceClass.POWER, + ), + PolestarSensorDescription( + key="charging_current_amps", + name="Charging Amps", + icon="mdi:current-ac", + query="getBatteryData", + field_name="chargingCurrentAmps", + unit='A', + round_digits=None, + max_value=None, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + PolestarSensorDescription( + key="charger_connection_status", + name="Charging power", + icon="mdi:battery-clock", + query="getBatteryData", + field_name="chargerConnectionStatus", + unit=None, + round_digits=None, + max_value=None, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + PolestarSensorDescription( + key="average_energy_consumption_kwh_per_100_km", + name="Average energy consumption", + icon="mdi:battery-clock", + query="getBatteryData", + field_name="averageEnergyConsumptionKwhPer100Km", + unit='Kwh/100km', + round_digits=None, + max_value=None, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + PolestarSensorDescription( + key="estimated_charging_time_minutes_to_target_distance", + name="Estimated charging time to target distance", + icon="mdi:battery-clock", + query="getBatteryData", + field_name="estimatedChargingTimeMinutesToTargetDistance", + unit=PERCENTAGE, + round_digits=None, + max_value=None, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + PolestarSensorDescription( + key="vin", + name="VIN", + icon="mdi:card-account-details", + query="getConsumerCarsV2", + field_name="vin", + unit=None, + round_digits=None, + max_value=None, + ), + PolestarSensorDescription( + key="registration_number", + name="Registration number", + icon="mdi:numeric-1-box", + query="getConsumerCarsV2", + field_name="registrationNo", + unit=None, + round_digits=None, max_value=None, ), PolestarSensorDescription( - key="charging_connection_status", - name="Charg. connection status", - icon="mdi:car", - path="{vin}/recharge-status", - response_path="chargingConnectionStatus.value", + key="estimated_fully_charged_time", + name="Est. Full charged", + icon="mdi:battery-clock", + query="getBatteryData", + field_name="estimatedChargingTimeToFullMinutes", unit=None, round_digits=None, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, max_value=None, ), PolestarSensorDescription( - key="charging_system_status", - name="Charg. system status", - icon="mdi:car", - path="{vin}/recharge-status", - response_path="chargingSystemStatus.value", + key="model_name", + name="Model name", + query="getConsumerCarsV2", + field_name="content/model/name", unit=None, round_digits=None, max_value=None, ), PolestarSensorDescription( - key="api_status_code", - name="API status", - icon="mdi:heart", - path=None, - response_path=None, + key="last_updated", + name="Last updated", + query="getOdometerData", + field_name="eventUpdatedTimestamp/iso", unit=None, round_digits=None, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TIMESTAMP, max_value=None, ), @@ -221,7 +313,7 @@ async def async_setup_entry( device: PolestarApi device = hass.data[POLESTAR_API_DOMAIN][entry.entry_id] # put data in cache - await device.get_data("{vin}/recharge-status") + await device.get_ev_data() sensors = [ PolestarSensor(device, description) for description in POLESTAR_SENSOR_TYPES @@ -260,12 +352,16 @@ def _get_current_value(self) -> StateType | None: """Get the current value.""" return self.async_update() + def get_skip_cache(self) -> bool: + """Get the skip cache.""" + return self.description.key in ('vin', 'registration_number', 'model_name') + @callback def _async_update_attrs(self) -> None: """Update the state and attributes.""" # try to fill the current cache data self._attr_native_value = self._device.get_cache_data( - self.description.path, self.description.response_path) + self.description.query, self.description.field_name, self.get_skip_cache()) @property def unique_id(self) -> str: @@ -303,9 +399,9 @@ def state(self) -> StateType: return None # parse the long text with a shorter one from the dict - if self.entity_description.key == 'charging_connection_status': + if self.entity_description.key == 'charger_connection_status': return ChargingConnectionStatusDict.get(self._attr_native_value, self._attr_native_value) - if self.entity_description.key == 'charging_system_status': + if self.entity_description.key == 'charging_status': return ChargingSystemStatusDict.get(self._attr_native_value, self._attr_native_value) # battery charge level contain ".0" at the end, this should be removed @@ -357,12 +453,26 @@ def state(self) -> StateType: return estimate_range - if self.entity_description.key == 'electric_range_miles': + if self.entity_description.key == 'current_odometer_meters' and int(self._attr_native_value) > 1000: + self._attr_native_value = int(self._attr_native_value) + km = round(self._attr_native_value / 1000, + self.entity_description.round_digits if self.entity_description.round_digits is not None else 0) + + return km + + if self.entity_description.key == 'current_trip_meter_manual' and int(self._attr_native_value) > 1000: + self._attr_native_value = int(self._attr_native_value) + km = round(self._attr_native_value / 1000, + self.entity_description.round_digits if self.entity_description.round_digits is not None else 0) + + return km + + if self.entity_description.key == 'current_trip_meter_automatic' and int(self._attr_native_value) > 1000: self._attr_native_value = int(self._attr_native_value) - miles = round(self._attr_native_value / 1.609344, - self.entity_description.round_digits if self.entity_description.round_digits is not None else 0) + km = round(self._attr_native_value / 1000, + self.entity_description.round_digits if self.entity_description.round_digits is not None else 0) - return miles + return km return self._attr_native_value @property @@ -372,9 +482,12 @@ def unit_of_measurement(self) -> str: 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) + data = self._device.get_cache_data( + self.entity_description.query, self.entity_description.field_name, self.get_skip_cache()) if data is None: - return + data = self._device.get_cache_data( + self.entity_description.query, self.entity_description.field_name, True) + await self._device.get_ev_data() self._attr_native_value = data self.value = data diff --git a/custom_components/polestar_api/strings.json b/custom_components/polestar_api/strings.json index 669f8e3..1a2104f 100644 --- a/custom_components/polestar_api/strings.json +++ b/custom_components/polestar_api/strings.json @@ -8,8 +8,7 @@ "name": "Friendly name", "username": "Username", "password": "Password", - "vin": "VIN", - "vcc_api_key": "VCC API Key" + "vin": "VIN" } } }, diff --git a/custom_components/polestar_api/translations/en.json b/custom_components/polestar_api/translations/en.json index 669f8e3..1a2104f 100644 --- a/custom_components/polestar_api/translations/en.json +++ b/custom_components/polestar_api/translations/en.json @@ -8,8 +8,7 @@ "name": "Friendly name", "username": "Username", "password": "Password", - "vin": "VIN", - "vcc_api_key": "VCC API Key" + "vin": "VIN" } } }, From 22f91ad85ebf1d78bdae7d44966da9c805439b9a Mon Sep 17 00:00:00 2001 From: Tuen Lee Date: Tue, 19 Dec 2023 19:19:17 +0100 Subject: [PATCH 5/6] Update README.md --- README.md | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index e749980..913ce8c 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,10 @@ Polestar API This application is not an official app affiliated with Polestar. -## Create a Polestar/Volvo Account -First you need to create a Polestar Developer account on this website: -https://developer.volvocars.com/apis/extended-vehicle/v1/overview/ -on your right side you can choose "Sign Up" and just follow the step. - -## Create 'vcc_api_key' -Sign into your Polestar Account and go to your account: -https://developer.volvocars.com/account/ -Create a new Application. In my case it call 'My Polestar' - -![image](https://github.com/leeyuentuen/polestar_api/assets/1487966/1e4694fe-90ee-4915-b198-55d6b084dc50) - -After create, you will see 2 vcc_api_key - -![image](https://github.com/leeyuentuen/polestar_api/assets/1487966/b660dffb-096d-4a15-afaa-7213fff24359) +## Use your Polestar account +This is the account that you also use in the polestar APP on your mobile phone +try here to login if it works or not: +https://polestarid.eu.polestar.com/PolestarLogin/login ## Add in HA Integration @@ -26,13 +15,10 @@ Add custom repository in HACS: https://github.com/leeyuentuen/polestar_api Search for integration 'polestar_api' and fill the information: -email and password: are these from your polestar developer account +email and password: this are the credential to login on your polestar account VIN: is the car identification number that you can find in your polestar app or polestar account -VCC api key: the key above that you have generate (i've take the first one) - -![image](https://github.com/leeyuentuen/polestar_api/assets/1487966/11d7586b-9d88-4b65-bd2b-0c5f66ff52fa) - -![image](https://github.com/leeyuentuen/polestar_api/assets/1487966/a8ae1b78-912b-40b5-9498-2534b07f4200) +![image](https://github.com/leeyuentuen/polestar_api/assets/1487966/30645415-ce93-4c73-ad60-6cbff78e691a) -Please note that the VCC_api_key provided is meant for testing purposes, allowing a limited number of calls, capped at 10,000 per day. I've attempted to restrict the calls in the code to enable caching, aiming to avoid surpassing this limit. However, a challenge with the home assistant integration is the absence of a fixed callback URL for registration +Result: +![image](https://github.com/leeyuentuen/polestar_api/assets/1487966/6805a981-4264-4ede-a331-448599be194a) From ad70f7440abd74e49cbf5cdc0209064a633cab7a Mon Sep 17 00:00:00 2001 From: Tuen Lee Date: Tue, 19 Dec 2023 19:21:10 +0100 Subject: [PATCH 6/6] optimized code --- .../polestar_api/polestar_api.py | 56 +++++++++++-------- custom_components/polestar_api/sensor.py | 49 +++++++++++----- 2 files changed, 67 insertions(+), 38 deletions(-) diff --git a/custom_components/polestar_api/polestar_api.py b/custom_components/polestar_api/polestar_api.py index 78e7c5f..da87576 100644 --- a/custom_components/polestar_api/polestar_api.py +++ b/custom_components/polestar_api/polestar_api.py @@ -2,6 +2,8 @@ import json import logging +from urllib.parse import parse_qs, urlparse + from .const import ( ACCESS_TOKEN_MANAGER_ID, AUTHORIZATION, @@ -59,20 +61,20 @@ async def _get_resume_path(self): if result.status != 200: _LOGGER.error(f"Error getting resume path {result.status}") return - return result.real_url.query_string + return result.real_url.raw_path_qs async def _get_code(self) -> None: resumePath = await self._get_resume_path() + parsed_url = urlparse(resumePath) + query_params = parse_qs(parsed_url.query) - # if resumepath has code, then we don't need to login - if 'code' in resumePath: - return resumePath.replace('code=', '') - - # if resumepath has resumepat + # check if code is in query_params + if query_params.get('code'): + return query_params.get(('code'))[0] - # get the realUrl - resumePath = resumePath.replace( - 'resumePath=', '').replace('&client_id=polmystar', '') + # get the resumePath + if query_params.get('resumePath'): + resumePath = query_params.get(('resumePath'))[0] if resumePath is None: return @@ -84,13 +86,25 @@ async def _get_code(self) -> None: 'pf.username': self.username, 'pf.pass': self.password } - result = await self._session.post(f"https://polestarid.eu.polestar.com/as/{resumePath}/resume/as/authorization.ping", params=params, data=data) + result = await self._session.post( + f"https://polestarid.eu.polestar.com/as/{resumePath}/resume/as/authorization.ping", + params=params, + data=data + ) if result.status != 200: _LOGGER.error(f"Error getting code {result.status}") return # get the realUrl url = result.url - code = result.url.query_string.replace('code=', '') + + parsed_url = urlparse(result.real_url.raw_path_qs) + query_params = parse_qs(parsed_url.query) + + if not query_params.get('code'): + _LOGGER.error(f"Error getting code {result.status}") + return + + code = query_params.get(('code'))[0] # sign-in-callback result = await self._session.get("https://www.polestar.com/sign-in-callback?code=" + code) @@ -109,10 +123,11 @@ async def get_token(self) -> None: # get token params = { - "query": "query getAuthToken($code: String!) { getAuthToken(code: $code) { id_token access_token refresh_token expires_in }}", + "query": "query getAuthToken($code: String!) { getAuthToken(code: $code) { id_token access_token refresh_token expires_in }}", "operationName": "getAuthToken", - "variables": "{\"code\":\"" + code + "\"}" + "variables": json.dumps({"code": code}) } + headers = { "Content-Type": "application/json" } @@ -129,19 +144,12 @@ async def get_token(self) -> None: _LOGGER.debug(f"Response {self.access_token}") - def get_latest_data(self, path: str, reponse_path: str = None) -> dict or bool or None: - # i don't care what cache is, just give me the latest data in the cache - # replace the string {vin} with the actual vin - path = path.replace('{vin}', self.vin) - - if self.cache_data and self.cache_data[path]: - data = self.cache_data[path]['data'] + def get_latest_data(self, query: str, field_name: str) -> dict or bool or None: + if self.cache_data and self.cache_data[query]: + data = self.cache_data[query]['data'] if data is None: return False - if reponse_path: - for key in reponse_path.split('.'): - data = data[key] - return data + return self._get_field_name_value(field_name, data) def _get_field_name_value(self, field_name: str, data: dict) -> str or bool or None: if '/' in field_name: diff --git a/custom_components/polestar_api/sensor.py b/custom_components/polestar_api/sensor.py index 711be05..d59ecdb 100644 --- a/custom_components/polestar_api/sensor.py +++ b/custom_components/polestar_api/sensor.py @@ -79,7 +79,7 @@ class PolestarSensorDescription( POLESTAR_SENSOR_TYPES: Final[tuple[PolestarSensorDescription, ...]] = ( PolestarSensorDescription( key="estimate_distance_to_empty_miles", - name="Est. distance miles", + name="Distance miles Remaining", icon="mdi:map-marker-distance", query="getBatteryData", field_name="estimatedDistanceToEmptyMiles", @@ -91,7 +91,7 @@ class PolestarSensorDescription( ), PolestarSensorDescription( key="estimate_distance_to_empty_km", - name="Est. distance km", + name="Distance km Remaining", icon="mdi:map-marker-distance", query="getBatteryData", field_name="estimatedDistanceToEmptyKm", @@ -115,7 +115,7 @@ class PolestarSensorDescription( ), PolestarSensorDescription( key="average_speed_km_per_hour", - name="Average Speed Per Hour", + name="Avg Speed Per Hour", icon="mdi:map-marker-distance", query="getOdometerData", field_name="averageSpeedKmPerHour", @@ -182,7 +182,7 @@ class PolestarSensorDescription( ), PolestarSensorDescription( key="charging_power_watts", - name="Charging Watt", + name="Charging Power", icon="mdi:lightning-bolt", query="getBatteryData", field_name="chargingPowerWatts", @@ -194,7 +194,7 @@ class PolestarSensorDescription( ), PolestarSensorDescription( key="charging_current_amps", - name="Charging Amps", + name="Charging Current", icon="mdi:current-ac", query="getBatteryData", field_name="chargingCurrentAmps", @@ -218,7 +218,7 @@ class PolestarSensorDescription( ), PolestarSensorDescription( key="average_energy_consumption_kwh_per_100_km", - name="Average energy consumption", + name="Avg. energy consumption", icon="mdi:battery-clock", query="getBatteryData", field_name="averageEnergyConsumptionKwhPer100Km", @@ -262,7 +262,7 @@ class PolestarSensorDescription( ), PolestarSensorDescription( key="estimated_fully_charged_time", - name="Est. Full charged", + name="Time Full charged", icon="mdi:battery-clock", query="getBatteryData", field_name="estimatedChargingTimeToFullMinutes", @@ -292,6 +292,30 @@ class PolestarSensorDescription( device_class=SensorDeviceClass.TIMESTAMP, max_value=None, ), + PolestarSensorDescription( + key="estimate_full_charge_range_miles", + name="Calc. miles Full Charge", + icon="mdi:map-marker-distance", + query="getBatteryData", + field_name="estimatedDistanceToEmptyMiles", + unit='miles', + round_digits=None, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + max_value=None + ), + PolestarSensorDescription( + key="estimate_full_charge_range", + name="Calc. km Full Charge", + icon="mdi:map-marker-distance", + query="getBatteryData", + field_name="estimatedDistanceToEmptyKm", + unit='km', + round_digits=None, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + max_value=None + ), ) @@ -395,7 +419,7 @@ def state(self) -> StateType: if self.entity_description.key == 'api_status_code': return API_STATUS_DICT.get(self._device.latest_call_code, "Error") - if self._attr_native_value in (None, False): + if self._attr_native_value != 0 and self._attr_native_value in (None, False): return None # parse the long text with a shorter one from the dict @@ -433,9 +457,9 @@ def state(self) -> StateType: if self.entity_description.key in ('estimate_full_charge_range', 'estimate_full_charge_range_miles'): battery_level = self._device.get_latest_data( - self.entity_description.path, 'batteryChargeLevel.value') + self.entity_description.query, 'batteryChargeLevelPercentage') estimate_range = self._device.get_latest_data( - self.entity_description.path, 'electricRange.value') + self.entity_description.query, self.entity_description.field_name) if battery_level is None or estimate_range is None: return None @@ -443,14 +467,11 @@ def state(self) -> StateType: if battery_level is False or estimate_range is False: return None - battery_level = int(battery_level.replace('.0', '')) + battery_level = int(battery_level) estimate_range = int(estimate_range) estimate_range = round(estimate_range / battery_level * 100) - if self.entity_description.key == 'estimate_full_charge_range_miles': - return round(estimate_range / 1.609344, self.entity_description.round_digits if self.entity_description.round_digits is not None else 0) - return estimate_range if self.entity_description.key == 'current_odometer_meters' and int(self._attr_native_value) > 1000: