"""Client for Renault API.""" from datetime import datetime from datetime import timezone from typing import Any from typing import cast import aiohttp from .credential_store import CredentialStore from .exceptions import EndpointNotAvailableError from .exceptions import RenaultException from .kamereon import ACCOUNT_ENDPOINT_ROOT from .kamereon import models from .kamereon import schemas from .renault_session import RenaultSession PERIOD_DAY_FORMAT = "%Y%m%d" PERIOD_MONTH_FORMAT = "%Y%m" PERIOD_TZ_FORMAT = "%Y-%m-%dT%H:%M:%SZ" PERIOD_FORMATS = {"day": PERIOD_DAY_FORMAT, "month": PERIOD_MONTH_FORMAT} class RenaultVehicle: """Proxy to a Renault vehicle.""" def __init__( self, account_id: str, vin: str, *, session: RenaultSession | None = None, websession: aiohttp.ClientSession | None = None, locale: str | None = None, country: str | None = None, locale_details: dict[str, str] | None = None, credential_store: CredentialStore | None = None, vehicle_details: models.KamereonVehicleDetails | None = None, car_adapter: models.KamereonVehicleCarAdapterData | None = None, ) -> None: """Initialise Renault vehicle.""" self._account_id = account_id self._vin = vin self._vehicle_details = vehicle_details self._car_adapter = car_adapter self._contracts: list[models.KamereonVehicleContract] | None = None if session: self._session = session else: if websession is None: raise RenaultException( "`websession` is required if session is not provided." ) self._session = RenaultSession( websession=websession, locale=locale, country=country, locale_details=locale_details, credential_store=credential_store, ) @property def session(self) -> RenaultSession: """Get session.""" return self._session @property def account_id(self) -> str: """Get account id.""" return self._account_id @property def vin(self) -> str: """Get vin.""" return self._vin def _convert_variables(self, endpoint: str) -> str: """Replace account_id / vin""" return endpoint.replace("{account_id}", self.account_id).replace( "{vin}", self.vin ) async def http_get(self, endpoint: str) -> models.KamereonResponse: """Run HTTP GET to endpoint.""" endpoint = self._convert_variables(endpoint) return await self.session.http_request("GET", endpoint) async def http_post( self, endpoint: str, json: dict[str, Any] | None = None ) -> models.KamereonResponse: """Run HTTP POST to endpoint.""" endpoint = self._convert_variables(endpoint) return await self.session.http_request("POST", endpoint, json) async def get_full_endpoint(self, endpoint: str) -> str: """From VEHICLE_ENDPOINTS / DEFAULT_ENDPOINT.""" endpoint_definition = await self.get_endpoint_definition(endpoint) return ACCOUNT_ENDPOINT_ROOT + endpoint_definition.endpoint async def get_endpoint_definition(self, endpoint: str) -> models.EndpointDefinition: """From VEHICLE_ENDPOINTS / DEFAULT_ENDPOINT.""" details = await self.get_details() full_endpoint = details.get_endpoint(endpoint) if full_endpoint is None: raise EndpointNotAvailableError(endpoint, details.get_model_code()) return full_endpoint async def _get_vehicle_data( self, endpoint: str | models.EndpointDefinition ) -> models.KamereonVehicleDataResponse: """GET to /v{endpoint_version}/cars/{vin}/{endpoint}.""" if isinstance(endpoint, models.EndpointDefinition): full_endpoint = ACCOUNT_ENDPOINT_ROOT + endpoint.endpoint else: full_endpoint = await self.get_full_endpoint(endpoint) response = await self.http_get(full_endpoint) return cast( models.KamereonVehicleDataResponse, schemas.KamereonVehicleDataResponseSchema.load(response.raw_data), ) async def _set_vehicle_data( self, endpoint: str | models.EndpointDefinition, json: dict[str, Any] | None, ) -> models.KamereonVehicleDataResponse: """GET to /v{endpoint_version}/cars/{vin}/{endpoint}.""" if isinstance(endpoint, models.EndpointDefinition): full_endpoint = ACCOUNT_ENDPOINT_ROOT + endpoint.endpoint else: full_endpoint = await self.get_full_endpoint(endpoint) response = await self.http_post(full_endpoint, json) return cast( models.KamereonVehicleDataResponse, schemas.KamereonVehicleDataResponseSchema.load(response.raw_data), ) async def get_details(self) -> models.KamereonVehicleDetails: """Get vehicle details.""" if self._vehicle_details: return self._vehicle_details response = await self.session.get_vehicle_details( account_id=self.account_id, vin=self.vin, ) self._vehicle_details = cast( models.KamereonVehicleDetails, response, ) return self._vehicle_details async def get_car_adapter(self) -> models.KamereonVehicleCarAdapterData: """Get vehicle car adapter details.""" if self._car_adapter: return self._car_adapter response = await self.session.get_vehicle_data( account_id=self.account_id, vin=self.vin, endpoint="", ) self._car_adapter = cast( models.KamereonVehicleCarAdapterData, response.get_attributes(schemas.KamereonVehicleCarAdapterDataSchema), ) return self._car_adapter async def get_contracts(self) -> list[models.KamereonVehicleContract]: """Get vehicle contracts.""" if self._contracts: return self._contracts response = await self.session.get_vehicle_contracts( account_id=self.account_id, vin=self.vin, ) if response.contractList is None: raise ValueError("response.contractList is None") self._contracts = response.contractList return self._contracts async def get_battery_status(self) -> models.KamereonVehicleBatteryStatusData: """Get vehicle battery status.""" response = await self._get_vehicle_data("battery-status") return cast( models.KamereonVehicleBatteryStatusData, response.get_attributes(schemas.KamereonVehicleBatteryStatusDataSchema), ) async def get_battery_soc(self) -> models.KamereonVehicleBatterySocData: """Get vehicle battery state of charge limits""" response = await self._get_vehicle_data("soc-levels") return cast( models.KamereonVehicleBatterySocData, schemas.KamereonVehicleBatterySocDataSchema.load(response.raw_data), ) async def get_tyre_pressure(self) -> models.KamereonVehicleTyrePressureData: """Get vehicle tyre pressure.""" response = await self._get_vehicle_data("pressure") return cast( models.KamereonVehicleTyrePressureData, response.get_attributes(schemas.KamereonVehicleTyrePressureDataSchema), ) async def get_location(self) -> models.KamereonVehicleLocationData: """Get vehicle location.""" response = await self._get_vehicle_data("location") return cast( models.KamereonVehicleLocationData, response.get_attributes(schemas.KamereonVehicleLocationDataSchema), ) async def get_hvac_status(self) -> models.KamereonVehicleHvacStatusData: """Get vehicle hvac status.""" response = await self._get_vehicle_data("hvac-status") return cast( models.KamereonVehicleHvacStatusData, response.get_attributes(schemas.KamereonVehicleHvacStatusDataSchema), ) async def get_hvac_settings(self) -> models.KamereonVehicleHvacSettingsData: """Get vehicle hvac settings (schedule+mode).""" response = await self._get_vehicle_data("hvac-settings") return cast( models.KamereonVehicleHvacSettingsData, response.get_attributes(schemas.KamereonVehicleHvacSettingsDataSchema), ) async def get_charge_mode(self) -> models.KamereonVehicleChargeModeData: """Get vehicle charge mode.""" response = await self._get_vehicle_data("charge-mode") return cast( models.KamereonVehicleChargeModeData, response.get_attributes(schemas.KamereonVehicleChargeModeDataSchema), ) async def get_charging_settings(self) -> models.KamereonVehicleChargingSettingsData: """Get vehicle charging settings.""" response = await self._get_vehicle_data("charging-settings") return cast( models.KamereonVehicleChargingSettingsData, response.get_attributes(schemas.KamereonVehicleChargingSettingsDataSchema), ) async def get_cockpit(self) -> models.KamereonVehicleCockpitData: """Get vehicle cockpit.""" response = await self._get_vehicle_data("cockpit") return cast( models.KamereonVehicleCockpitData, response.get_attributes(schemas.KamereonVehicleCockpitDataSchema), ) async def get_lock_status(self) -> models.KamereonVehicleLockStatusData: """Get vehicle lock status.""" response = await self._get_vehicle_data("lock-status") return cast( models.KamereonVehicleLockStatusData, response.get_attributes(schemas.KamereonVehicleLockStatusDataSchema), ) async def get_res_state(self) -> models.KamereonVehicleResStateData: """Get vehicle res state.""" response = await self._get_vehicle_data("res-state") return cast( models.KamereonVehicleResStateData, response.get_attributes(schemas.KamereonVehicleResStateDataSchema), ) async def get_charge_schedule(self) -> dict[str, Any]: """Get vehicle charge schedule.""" endpoint_definition = await self.get_endpoint_definition("charge-schedule") response = await self._get_vehicle_data(endpoint_definition) if endpoint_definition.mode == "kcm-settings": return response.raw_data return response.raw_data["data"]["attributes"] # type:ignore[no-any-return] async def get_notification_settings( self, ) -> models.KamereonVehicleNotificationSettingsData: """Get vehicle notification settings.""" response = await self._get_vehicle_data("notification-settings") return cast( models.KamereonVehicleNotificationSettingsData, response.get_attributes( schemas.KamereonVehicleNotificationSettingsDataSchema ), ) async def get_charge_history( self, start: datetime, end: datetime, period: str ) -> models.KamereonVehicleChargeHistoryData: """Get vehicle charge history.""" if not isinstance(start, datetime): raise TypeError( "`start` should be an instance of datetime.datetime, " f"not {start.__class__}" ) if not isinstance(end, datetime): raise TypeError( f"`end` should be an instance of datetime.datetime, not {end.__class__}" ) if period not in PERIOD_FORMATS.keys(): raise TypeError("`period` should be one of `month`, `day`") params = { "type": period, "start": start.strftime(PERIOD_FORMATS[period]), "end": end.strftime(PERIOD_FORMATS[period]), } response = await self.session.get_vehicle_data( account_id=self.account_id, vin=self.vin, endpoint="charge-history", params=params, ) return cast( models.KamereonVehicleChargeHistoryData, response.get_attributes(schemas.KamereonVehicleChargeHistoryDataSchema), ) async def get_charges( self, start: datetime, end: datetime ) -> models.KamereonVehicleChargesData: """Get vehicle charges.""" if not isinstance(start, datetime): raise TypeError( "`start` should be an instance of datetime.datetime, " f"not {start.__class__}" ) if not isinstance(end, datetime): raise TypeError( f"`end` should be an instance of datetime.datetime, not {end.__class__}" ) params = { "start": start.strftime(PERIOD_DAY_FORMAT), "end": end.strftime(PERIOD_DAY_FORMAT), } response = await self.session.get_vehicle_data( account_id=self.account_id, vin=self.vin, endpoint="charges", params=params, ) return cast( models.KamereonVehicleChargesData, response.get_attributes(schemas.KamereonVehicleChargesDataSchema), ) async def get_hvac_history( self, start: datetime, end: datetime, period: str ) -> models.KamereonVehicleHvacHistoryData: """Get vehicle hvac history.""" if not isinstance(start, datetime): raise TypeError( "`start` should be an instance of datetime.datetime, " f"not {start.__class__}" ) if not isinstance(end, datetime): raise TypeError( f"`end` should be an instance of datetime.datetime, not {end.__class__}" ) if period not in PERIOD_FORMATS.keys(): raise TypeError("`period` should be one of `month`, `day`") params = { "type": period, "start": start.strftime(PERIOD_FORMATS[period]), "end": end.strftime(PERIOD_FORMATS[period]), } response = await self.session.get_vehicle_data( account_id=self.account_id, vin=self.vin, endpoint="hvac-history", params=params, ) return cast( models.KamereonVehicleHvacHistoryData, response.get_attributes(schemas.KamereonVehicleHvacHistoryDataSchema), ) async def get_hvac_sessions( self, start: datetime, end: datetime ) -> models.KamereonVehicleHvacSessionsData: """Get vehicle hvac sessions.""" if not isinstance(start, datetime): raise TypeError( "`start` should be an instance of datetime.datetime, " f"not {start.__class__}" ) if not isinstance(end, datetime): raise TypeError( f"`end` should be an instance of datetime.datetime, not {end.__class__}" ) params = { "start": start.strftime(PERIOD_DAY_FORMAT), "end": end.strftime(PERIOD_DAY_FORMAT), } response = await self.session.get_vehicle_data( account_id=self.account_id, vin=self.vin, endpoint="hvac-sessions", params=params, ) return cast( models.KamereonVehicleHvacSessionsData, response.get_attributes(schemas.KamereonVehicleHvacSessionsDataSchema), ) async def set_ac_start( self, temperature: float, when: datetime | None = None ) -> models.KamereonVehicleHvacStartActionData: """Start vehicle ac.""" json: dict[str, Any] = { "data": { "type": "HvacStart", "attributes": { "action": "start", "targetTemperature": temperature, }, } } if when: if not isinstance(when, datetime): raise TypeError( "`when` should be an instance of datetime.datetime, " f"not {when.__class__}" ) start_date_time = when.astimezone(timezone.utc).strftime(PERIOD_TZ_FORMAT) json["data"]["attributes"]["startDateTime"] = start_date_time response = await self._set_vehicle_data("actions/hvac-start", json) return cast( models.KamereonVehicleHvacStartActionData, response.get_attributes(schemas.KamereonVehicleHvacStartActionDataSchema), ) async def set_ac_stop(self) -> models.KamereonVehicleHvacStartActionData: """Stop vehicle ac.""" json: dict[str, Any] = { "data": { "type": "HvacStart", "attributes": { "action": "cancel", }, } } # For some vehicles (eg A5E1AE), hvac is stopped with "stop" instead of "cancel" # so we override the action. It could be that this is the case for all vehicles, # but we have no way to test that. endpoint_definition = await self.get_endpoint_definition("actions/hvac-stop") if endpoint_definition.mode == "kca-stop": # Using alternative endpoint that requires "stop" action json["data"]["attributes"]["action"] = "stop" response = await self._set_vehicle_data(endpoint_definition, json) return cast( models.KamereonVehicleHvacStartActionData, response.get_attributes(schemas.KamereonVehicleHvacStartActionDataSchema), ) async def set_battery_soc( self, *, min: int, target: int ) -> models.KamereonVehicleBatterySocActionData: """Sets Battery Soc levels.""" json: dict[str, Any] = {"socMin": min, "socTarget": target} response = await self._set_vehicle_data("soc-levels", json) return cast( models.KamereonVehicleBatterySocActionData, response.get_attributes(schemas.KamereonVehicleBatterySocActionDataSchema), ) async def set_hvac_schedules( self, schedules: list[models.HvacSchedule] ) -> models.KamereonVehicleHvacScheduleActionData: """Set vehicle charge schedules.""" for schedule in schedules: if not isinstance(schedule, models.HvacSchedule): raise TypeError( "`schedules` should be a list of HvacSchedule, " f"not {schedules.__class__}" ) json: dict[str, Any] = { "data": { "type": "HvacSchedule", "attributes": { "schedules": [schedule.for_json() for schedule in schedules] }, } } response = await self._set_vehicle_data("actions/hvac-set-schedule", json) return cast( models.KamereonVehicleHvacScheduleActionData, response.get_attributes( schemas.KamereonVehicleHvacScheduleActionDataSchema ), ) async def set_charge_schedules( self, schedules: list[models.ChargeSchedule] ) -> models.KamereonVehicleChargeScheduleActionData: """Set vehicle charge schedules.""" for schedule in schedules: if not isinstance(schedule, models.ChargeSchedule): raise TypeError( "`schedules` should be a list of ChargeSchedule, " f"not {schedules.__class__}" ) json: dict[str, Any] = { "data": { "type": "ChargeSchedule", "attributes": { "schedules": [schedule.for_json() for schedule in schedules] }, } } response = await self._set_vehicle_data("actions/charge-set-schedule", json) return cast( models.KamereonVehicleChargeScheduleActionData, response.get_attributes( schemas.KamereonVehicleChargeScheduleActionDataSchema ), ) async def set_charge_mode( self, charge_mode: str ) -> models.KamereonVehicleChargeModeActionData: """Set vehicle charge mode.""" json: dict[str, Any] = { "data": { "type": "ChargeMode", "attributes": { "action": charge_mode, }, } } response = await self._set_vehicle_data("actions/charge-set-mode", json) return cast( models.KamereonVehicleChargeModeActionData, response.get_attributes(schemas.KamereonVehicleChargeModeActionDataSchema), ) async def set_charge_start( self, when: datetime | None = None ) -> models.KamereonVehicleChargingStartActionData: """Start vehicle charge with optional delay.""" endpoint_definition = await self.get_endpoint_definition("actions/charge-start") json: dict[str, Any] if endpoint_definition.mode == "kcm-settings": # For vehicles like Renault 5 E-TECH, Scenic E-TECH that use ev/settings # endpoint. Based on analysis of MyRenault app behavior (issue #1348): # - GET current settings # - Disable all scheduled programs (programActivationStatus: false) # - POST the modified settings back # This triggers immediate charging by disabling scheduled mode. get_settings_response = await self._get_vehicle_data(endpoint_definition) current_settings = get_settings_response.raw_data # Disable all programs to trigger immediate charging if "programs" in current_settings: for program in current_settings["programs"]: program["programActivationStatus"] = False json = current_settings elif endpoint_definition.mode == "kcm-pause-resume": json = { "data": { "type": "ChargePauseResume", "attributes": { "action": "resume", }, } } elif endpoint_definition.mode == "kcm": json = { "data": { "type": "ChargingStart", "attributes": { "action": "start", }, } } if when: start_date_time = when.astimezone(timezone.utc).strftime( PERIOD_TZ_FORMAT ) json["data"]["attributes"]["startDateTime"] = start_date_time else: json = { "data": { "type": "ChargingStart", "attributes": { "action": "start", }, } } response = await self._set_vehicle_data(endpoint_definition, json) return cast( models.KamereonVehicleChargingStartActionData, response.get_attributes( schemas.KamereonVehicleChargingStartActionDataSchema ), ) async def set_charge_stop(self) -> models.KamereonVehicleChargingStartActionData: """Start vehicle charge.""" endpoint_definition = await self.get_endpoint_definition("actions/charge-stop") json: dict[str, Any] if endpoint_definition.mode == "kcm-pause-resume": json = { "data": { "type": "ChargePauseResume", "attributes": { "action": "pause", }, } } else: json = { "data": { "type": "ChargingStart", "attributes": { "action": "stop", }, } } response = await self._set_vehicle_data(endpoint_definition, json) return cast( models.KamereonVehicleChargingStartActionData, response.get_attributes( schemas.KamereonVehicleChargingStartActionDataSchema ), ) async def start_horn(self) -> dict[str, Any]: json: dict[str, Any] = { "data": { "type": "HornLights", "attributes": {"action": "start", "target": "horn"}, } } response = await self._set_vehicle_data("actions/horn-start", json) return response.raw_data async def start_lights(self) -> dict[str, Any]: json: dict[str, Any] = { "data": { "type": "HornLights", "attributes": {"action": "start", "target": "lights"}, } } response = await self._set_vehicle_data("actions/lights-start", json) return response.raw_data async def refresh_location(self) -> dict[str, Any]: json: dict[str, Any] = { "data": { "type": "RefreshLocation", } } response = await self._set_vehicle_data("actions/refresh-location", json) return response.raw_data async def supports_endpoint(self, endpoint: str) -> bool: """Check if vehicle supports endpoint.""" details = await self.get_details() return details.supports_endpoint(endpoint)