From 8d84edd3b79a179bc37bd5790c55ed6deff4facf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 28 Jul 2021 21:41:11 +0200 Subject: [PATCH] Add renault integration (#39605) --- .strict-typing | 1 + CODEOWNERS | 1 + homeassistant/components/renault/__init__.py | 45 +++ .../components/renault/config_flow.py | 92 +++++ homeassistant/components/renault/const.py | 15 + .../components/renault/manifest.json | 13 + .../components/renault/renault_coordinator.py | 72 ++++ .../components/renault/renault_entities.py | 103 ++++++ .../components/renault/renault_hub.py | 78 +++++ .../components/renault/renault_vehicle.py | 146 ++++++++ homeassistant/components/renault/sensor.py | 277 +++++++++++++++ homeassistant/components/renault/strings.json | 27 ++ .../components/renault/translations/en.json | 27 ++ homeassistant/generated/config_flows.py | 1 + mypy.ini | 11 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/renault/__init__.py | 159 +++++++++ tests/components/renault/const.py | 328 ++++++++++++++++++ tests/components/renault/test_config_flow.py | 137 ++++++++ tests/components/renault/test_init.py | 85 +++++ tests/components/renault/test_sensor.py | 212 +++++++++++ .../renault/battery_status_charging.json | 18 + .../renault/battery_status_not_charging.json | 15 + .../fixtures/renault/charge_mode_always.json | 7 + .../renault/charge_mode_schedule.json | 7 + tests/fixtures/renault/cockpit_ev.json | 9 + tests/fixtures/renault/cockpit_fuel.json | 11 + tests/fixtures/renault/hvac_status.json | 7 + .../fixtures/renault/vehicle_captur_fuel.json | 108 ++++++ .../fixtures/renault/vehicle_captur_phev.json | 110 ++++++ tests/fixtures/renault/vehicle_zoe_40.json | 189 ++++++++++ tests/fixtures/renault/vehicle_zoe_50.json | 161 +++++++++ 33 files changed, 2478 insertions(+) create mode 100644 homeassistant/components/renault/__init__.py create mode 100644 homeassistant/components/renault/config_flow.py create mode 100644 homeassistant/components/renault/const.py create mode 100644 homeassistant/components/renault/manifest.json create mode 100644 homeassistant/components/renault/renault_coordinator.py create mode 100644 homeassistant/components/renault/renault_entities.py create mode 100644 homeassistant/components/renault/renault_hub.py create mode 100644 homeassistant/components/renault/renault_vehicle.py create mode 100644 homeassistant/components/renault/sensor.py create mode 100644 homeassistant/components/renault/strings.json create mode 100644 homeassistant/components/renault/translations/en.json create mode 100644 tests/components/renault/__init__.py create mode 100644 tests/components/renault/const.py create mode 100644 tests/components/renault/test_config_flow.py create mode 100644 tests/components/renault/test_init.py create mode 100644 tests/components/renault/test_sensor.py create mode 100644 tests/fixtures/renault/battery_status_charging.json create mode 100644 tests/fixtures/renault/battery_status_not_charging.json create mode 100644 tests/fixtures/renault/charge_mode_always.json create mode 100644 tests/fixtures/renault/charge_mode_schedule.json create mode 100644 tests/fixtures/renault/cockpit_ev.json create mode 100644 tests/fixtures/renault/cockpit_fuel.json create mode 100644 tests/fixtures/renault/hvac_status.json create mode 100644 tests/fixtures/renault/vehicle_captur_fuel.json create mode 100644 tests/fixtures/renault/vehicle_captur_phev.json create mode 100644 tests/fixtures/renault/vehicle_zoe_40.json create mode 100644 tests/fixtures/renault/vehicle_zoe_50.json diff --git a/.strict-typing b/.strict-typing index e32af5db563..6066c158b99 100644 --- a/.strict-typing +++ b/.strict-typing @@ -81,6 +81,7 @@ homeassistant.components.recorder.purge homeassistant.components.recorder.repack homeassistant.components.recorder.statistics homeassistant.components.remote.* +homeassistant.components.renault.* homeassistant.components.rituals_perfume_genie.* homeassistant.components.scene.* homeassistant.components.select.* diff --git a/CODEOWNERS b/CODEOWNERS index bfd74e97f71..c4cb6d242d0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -411,6 +411,7 @@ homeassistant/components/rainmachine/* @bachya homeassistant/components/random/* @fabaff homeassistant/components/recollect_waste/* @bachya homeassistant/components/rejseplanen/* @DarkFox +homeassistant/components/renault/* @epenet homeassistant/components/repetier/* @MTrab homeassistant/components/rflink/* @javicalle homeassistant/components/rfxtrx/* @danielhiversen @elupus @RobBie1221 diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py new file mode 100644 index 00000000000..80433b2106e --- /dev/null +++ b/homeassistant/components/renault/__init__.py @@ -0,0 +1,45 @@ +"""Support for Renault devices.""" +import aiohttp + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_LOCALE, DOMAIN, PLATFORMS +from .renault_hub import RenaultHub + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Load a config entry.""" + renault_hub = RenaultHub(hass, config_entry.data[CONF_LOCALE]) + try: + login_success = await renault_hub.attempt_login( + config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD] + ) + except aiohttp.ClientConnectionError as exc: + raise ConfigEntryNotReady() from exc + + if not login_success: + return False + + hass.data.setdefault(DOMAIN, {}) + await renault_hub.async_initialise(config_entry) + + hass.data[DOMAIN][config_entry.unique_id] = renault_hub + + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + + if unload_ok: + hass.data[DOMAIN].pop(config_entry.unique_id) + + return unload_ok diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py new file mode 100644 index 00000000000..09a69f1f95f --- /dev/null +++ b/homeassistant/components/renault/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow to configure Renault component.""" +from __future__ import annotations + +from typing import Any + +from renault_api.const import AVAILABLE_LOCALES +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, DOMAIN +from .renault_hub import RenaultHub + + +class RenaultFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Renault config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the Renault config flow.""" + self.renault_config: dict[str, Any] = {} + self.renault_hub: RenaultHub | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a Renault config flow start. + + Ask the user for API keys. + """ + if user_input: + locale = user_input[CONF_LOCALE] + self.renault_config.update(user_input) + self.renault_config.update(AVAILABLE_LOCALES[locale]) + self.renault_hub = RenaultHub(self.hass, locale) + if not await self.renault_hub.attempt_login( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ): + return self._show_user_form({"base": "invalid_credentials"}) + return await self.async_step_kamereon() + return self._show_user_form() + + def _show_user_form(self, errors: dict[str, Any] | None = None) -> FlowResult: + """Show the API keys form.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors or {}, + ) + + async def async_step_kamereon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Select Kamereon account.""" + if user_input: + await self.async_set_unique_id(user_input[CONF_KAMEREON_ACCOUNT_ID]) + self._abort_if_unique_id_configured() + + self.renault_config.update(user_input) + return self.async_create_entry( + title=user_input[CONF_KAMEREON_ACCOUNT_ID], data=self.renault_config + ) + + assert self.renault_hub + accounts = await self.renault_hub.get_account_ids() + if len(accounts) == 0: + return self.async_abort(reason="kamereon_no_account") + if len(accounts) == 1: + await self.async_set_unique_id(accounts[0]) + self._abort_if_unique_id_configured() + + self.renault_config[CONF_KAMEREON_ACCOUNT_ID] = accounts[0] + return self.async_create_entry( + title=self.renault_config[CONF_KAMEREON_ACCOUNT_ID], + data=self.renault_config, + ) + + return self.async_show_form( + step_id="kamereon", + data_schema=vol.Schema( + {vol.Required(CONF_KAMEREON_ACCOUNT_ID): vol.In(accounts)} + ), + ) diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py new file mode 100644 index 00000000000..51f6c10c6f1 --- /dev/null +++ b/homeassistant/components/renault/const.py @@ -0,0 +1,15 @@ +"""Constants for the Renault component.""" +DOMAIN = "renault" + +CONF_LOCALE = "locale" +CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" + +DEFAULT_SCAN_INTERVAL = 300 # 5 minutes + +PLATFORMS = [ + "sensor", +] + +DEVICE_CLASS_PLUG_STATE = "renault__plug_state" +DEVICE_CLASS_CHARGE_STATE = "renault__charge_state" +DEVICE_CLASS_CHARGE_MODE = "renault__charge_mode" diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json new file mode 100644 index 00000000000..118848ad6dd --- /dev/null +++ b/homeassistant/components/renault/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "renault", + "name": "Renault", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/renault", + "requirements": [ + "renault-api==0.1.4" + ], + "codeowners": [ + "@epenet" + ], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/renault/renault_coordinator.py b/homeassistant/components/renault/renault_coordinator.py new file mode 100644 index 00000000000..b47a8507030 --- /dev/null +++ b/homeassistant/components/renault/renault_coordinator.py @@ -0,0 +1,72 @@ +"""Proxy to handle account communication with Renault servers.""" +from __future__ import annotations + +from collections.abc import Awaitable +from datetime import timedelta +import logging +from typing import Callable, TypeVar + +from renault_api.kamereon.exceptions import ( + AccessDeniedException, + KamereonResponseException, + NotSupportedException, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +T = TypeVar("T") + + +class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): + """Handle vehicle communication with Renault servers.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + *, + name: str, + update_interval: timedelta, + update_method: Callable[[], Awaitable[T]], + ) -> None: + """Initialise coordinator.""" + super().__init__( + hass, + logger, + name=name, + update_interval=update_interval, + update_method=update_method, + ) + self.access_denied = False + self.not_supported = False + + async def _async_update_data(self) -> T: + """Fetch the latest data from the source.""" + if self.update_method is None: + raise NotImplementedError("Update method not implemented") + try: + return await self.update_method() + except AccessDeniedException as err: + # Disable because the account is not allowed to access this Renault endpoint. + self.update_interval = None + self.access_denied = True + raise UpdateFailed(f"This endpoint is denied: {err}") from err + + except NotSupportedException as err: + # Disable because the vehicle does not support this Renault endpoint. + self.update_interval = None + self.not_supported = True + raise UpdateFailed(f"This endpoint is not supported: {err}") from err + + except KamereonResponseException as err: + # Other Renault errors. + raise UpdateFailed(f"Error communicating with API: {err}") from err + + async def async_config_entry_first_refresh(self) -> None: + """Refresh data for the first time when a config entry is setup. + + Contrary to base implementation, we are not raising ConfigEntryNotReady + but only updating the `access_denied` and `not_supported` flags. + """ + await self._async_refresh(log_failures=False, raise_on_auth_failed=True) diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/renault_entities.py new file mode 100644 index 00000000000..9188a1f0757 --- /dev/null +++ b/homeassistant/components/renault/renault_entities.py @@ -0,0 +1,103 @@ +"""Base classes for Renault entities.""" +from __future__ import annotations + +from typing import Any, Generic, Optional, TypeVar + +from renault_api.kamereon.enums import ChargeState, PlugState +from renault_api.kamereon.models import ( + KamereonVehicleBatteryStatusData, + KamereonVehicleChargeModeData, + KamereonVehicleCockpitData, + KamereonVehicleHvacStatusData, +) + +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify + +from .renault_vehicle import RenaultVehicleProxy + +ATTR_LAST_UPDATE = "last_update" + +T = TypeVar("T") + + +class RenaultDataEntity(Generic[T], CoordinatorEntity[Optional[T]], Entity): + """Implementation of a Renault entity with a data coordinator.""" + + def __init__( + self, vehicle: RenaultVehicleProxy, entity_type: str, coordinator_key: str + ) -> None: + """Initialise entity.""" + super().__init__(vehicle.coordinators[coordinator_key]) + self.vehicle = vehicle + self._entity_type = entity_type + self._attr_device_info = self.vehicle.device_info + self._attr_name = entity_type + self._attr_unique_id = slugify( + f"{self.vehicle.details.vin}-{self._entity_type}" + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + # Data can succeed, but be empty + return super().available and self.coordinator.data is not None + + @property + def data(self) -> T | None: + """Return collected data.""" + return self.coordinator.data + + +class RenaultBatteryDataEntity(RenaultDataEntity[KamereonVehicleBatteryStatusData]): + """Implementation of a Renault entity with battery coordinator.""" + + def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: + """Initialise entity.""" + super().__init__(vehicle, entity_type, "battery") + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of this entity.""" + last_update = self.data.timestamp if self.data else None + return {ATTR_LAST_UPDATE: last_update} + + @property + def is_charging(self) -> bool: + """Return charge state as boolean.""" + return ( + self.data is not None + and self.data.get_charging_status() == ChargeState.CHARGE_IN_PROGRESS + ) + + @property + def is_plugged_in(self) -> bool: + """Return plug state as boolean.""" + return ( + self.data is not None and self.data.get_plug_status() == PlugState.PLUGGED + ) + + +class RenaultChargeModeDataEntity(RenaultDataEntity[KamereonVehicleChargeModeData]): + """Implementation of a Renault entity with charge_mode coordinator.""" + + def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: + """Initialise entity.""" + super().__init__(vehicle, entity_type, "charge_mode") + + +class RenaultCockpitDataEntity(RenaultDataEntity[KamereonVehicleCockpitData]): + """Implementation of a Renault entity with cockpit coordinator.""" + + def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: + """Initialise entity.""" + super().__init__(vehicle, entity_type, "cockpit") + + +class RenaultHVACDataEntity(RenaultDataEntity[KamereonVehicleHvacStatusData]): + """Implementation of a Renault entity with hvac_status coordinator.""" + + def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: + """Initialise entity.""" + super().__init__(vehicle, entity_type, "hvac_status") diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py new file mode 100644 index 00000000000..51e356934bb --- /dev/null +++ b/homeassistant/components/renault/renault_hub.py @@ -0,0 +1,78 @@ +"""Proxy to handle account communication with Renault servers.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from renault_api.gigya.exceptions import InvalidCredentialsException +from renault_api.renault_account import RenaultAccount +from renault_api.renault_client import RenaultClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL +from .renault_vehicle import RenaultVehicleProxy + +LOGGER = logging.getLogger(__name__) + + +class RenaultHub: + """Handle account communication with Renault servers.""" + + def __init__(self, hass: HomeAssistant, locale: str) -> None: + """Initialise proxy.""" + LOGGER.debug("Creating RenaultHub") + self._hass = hass + self._client = RenaultClient( + websession=async_get_clientsession(self._hass), locale=locale + ) + self._account: RenaultAccount | None = None + self._vehicles: dict[str, RenaultVehicleProxy] = {} + + async def attempt_login(self, username: str, password: str) -> bool: + """Attempt login to Renault servers.""" + try: + await self._client.session.login(username, password) + except InvalidCredentialsException as ex: + LOGGER.error("Login to Renault failed: %s", ex.error_details) + else: + return True + return False + + async def async_initialise(self, config_entry: ConfigEntry) -> None: + """Set up proxy.""" + account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID] + scan_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL) + + self._account = await self._client.get_api_account(account_id) + vehicles = await self._account.get_vehicles() + if vehicles.vehicleLinks: + for vehicle_link in vehicles.vehicleLinks: + if vehicle_link.vin and vehicle_link.vehicleDetails: + # Generate vehicle proxy + vehicle = RenaultVehicleProxy( + hass=self._hass, + vehicle=await self._account.get_api_vehicle(vehicle_link.vin), + details=vehicle_link.vehicleDetails, + scan_interval=scan_interval, + ) + await vehicle.async_initialise() + self._vehicles[vehicle_link.vin] = vehicle + + async def get_account_ids(self) -> list[str]: + """Get Kamereon account ids.""" + accounts = [] + for account in await self._client.get_api_accounts(): + vehicles = await account.get_vehicles() + + # Only add the account if it has linked vehicles. + if vehicles.vehicleLinks: + accounts.append(account.account_id) + return accounts + + @property + def vehicles(self) -> dict[str, RenaultVehicleProxy]: + """Get list of vehicles.""" + return self._vehicles diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py new file mode 100644 index 00000000000..09e3de9adab --- /dev/null +++ b/homeassistant/components/renault/renault_vehicle.py @@ -0,0 +1,146 @@ +"""Proxy to handle account communication with Renault servers.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging +from typing import cast + +from renault_api.kamereon import models +from renault_api.renault_vehicle import RenaultVehicle + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo + +from .const import DOMAIN +from .renault_coordinator import RenaultDataUpdateCoordinator + +LOGGER = logging.getLogger(__name__) + + +class RenaultVehicleProxy: + """Handle vehicle communication with Renault servers.""" + + def __init__( + self, + hass: HomeAssistant, + vehicle: RenaultVehicle, + details: models.KamereonVehicleDetails, + scan_interval: timedelta, + ) -> None: + """Initialise vehicle proxy.""" + self.hass = hass + self._vehicle = vehicle + self._details = details + self._device_info: DeviceInfo = { + "identifiers": {(DOMAIN, cast(str, details.vin))}, + "manufacturer": (details.get_brand_label() or "").capitalize(), + "model": (details.get_model_label() or "").capitalize(), + "name": details.registrationNumber or "", + "sw_version": details.get_model_code() or "", + } + self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {} + self.hvac_target_temperature = 21 + self._scan_interval = scan_interval + + @property + def details(self) -> models.KamereonVehicleDetails: + """Return the specs of the vehicle.""" + return self._details + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + return self._device_info + + async def async_initialise(self) -> None: + """Load available sensors.""" + if await self.endpoint_available("cockpit"): + self.coordinators["cockpit"] = RenaultDataUpdateCoordinator( + self.hass, + LOGGER, + # Name of the data. For logging purposes. + name=f"{self.details.vin} cockpit", + update_method=self.get_cockpit, + # Polling interval. Will only be polled if there are subscribers. + update_interval=self._scan_interval, + ) + if await self.endpoint_available("hvac-status"): + self.coordinators["hvac_status"] = RenaultDataUpdateCoordinator( + self.hass, + LOGGER, + # Name of the data. For logging purposes. + name=f"{self.details.vin} hvac_status", + update_method=self.get_hvac_status, + # Polling interval. Will only be polled if there are subscribers. + update_interval=self._scan_interval, + ) + if self.details.uses_electricity(): + if await self.endpoint_available("battery-status"): + self.coordinators["battery"] = RenaultDataUpdateCoordinator( + self.hass, + LOGGER, + # Name of the data. For logging purposes. + name=f"{self.details.vin} battery", + update_method=self.get_battery_status, + # Polling interval. Will only be polled if there are subscribers. + update_interval=self._scan_interval, + ) + if await self.endpoint_available("charge-mode"): + self.coordinators["charge_mode"] = RenaultDataUpdateCoordinator( + self.hass, + LOGGER, + # Name of the data. For logging purposes. + name=f"{self.details.vin} charge_mode", + update_method=self.get_charge_mode, + # Polling interval. Will only be polled if there are subscribers. + update_interval=self._scan_interval, + ) + # Check all coordinators + await asyncio.gather( + *( + coordinator.async_config_entry_first_refresh() + for coordinator in self.coordinators.values() + ) + ) + for key in list(self.coordinators): + # list() to avoid Runtime iteration error + coordinator = self.coordinators[key] + if coordinator.not_supported: + # Remove endpoint as it is not supported for this vehicle. + LOGGER.error( + "Ignoring endpoint %s as it is not supported for this vehicle: %s", + coordinator.name, + coordinator.last_exception, + ) + del self.coordinators[key] + elif coordinator.access_denied: + # Remove endpoint as it is denied for this vehicle. + LOGGER.error( + "Ignoring endpoint %s as it is denied for this vehicle: %s", + coordinator.name, + coordinator.last_exception, + ) + del self.coordinators[key] + + async def endpoint_available(self, endpoint: str) -> bool: + """Ensure the endpoint is available to avoid unnecessary queries.""" + return await self._vehicle.supports_endpoint( + endpoint + ) and await self._vehicle.has_contract_for_endpoint(endpoint) + + async def get_battery_status(self) -> models.KamereonVehicleBatteryStatusData: + """Get battery status information from vehicle.""" + return await self._vehicle.get_battery_status() + + async def get_charge_mode(self) -> models.KamereonVehicleChargeModeData: + """Get charge mode information from vehicle.""" + return await self._vehicle.get_charge_mode() + + async def get_cockpit(self) -> models.KamereonVehicleCockpitData: + """Get cockpit information from vehicle.""" + return await self._vehicle.get_cockpit() + + async def get_hvac_status(self) -> models.KamereonVehicleHvacStatusData: + """Get hvac status information from vehicle.""" + return await self._vehicle.get_hvac_status() diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py new file mode 100644 index 00000000000..8403a04d001 --- /dev/null +++ b/homeassistant/components/renault/sensor.py @@ -0,0 +1,277 @@ +"""Support for Renault sensors.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_TEMPERATURE, + LENGTH_KILOMETERS, + PERCENTAGE, + POWER_KILO_WATT, + TEMP_CELSIUS, + TIME_MINUTES, + VOLUME_LITERS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.util import slugify + +from .const import ( + DEVICE_CLASS_CHARGE_MODE, + DEVICE_CLASS_CHARGE_STATE, + DEVICE_CLASS_PLUG_STATE, + DOMAIN, +) +from .renault_entities import ( + RenaultBatteryDataEntity, + RenaultChargeModeDataEntity, + RenaultCockpitDataEntity, + RenaultDataEntity, + RenaultHVACDataEntity, +) +from .renault_hub import RenaultHub +from .renault_vehicle import RenaultVehicleProxy + +ATTR_BATTERY_AVAILABLE_ENERGY = "battery_available_energy" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renault entities from config entry.""" + proxy: RenaultHub = hass.data[DOMAIN][config_entry.unique_id] + entities = await get_entities(proxy) + async_add_entities(entities) + + +async def get_entities(proxy: RenaultHub) -> list[RenaultDataEntity]: + """Create Renault entities for all vehicles.""" + entities = [] + for vehicle in proxy.vehicles.values(): + entities.extend(await get_vehicle_entities(vehicle)) + return entities + + +async def get_vehicle_entities(vehicle: RenaultVehicleProxy) -> list[RenaultDataEntity]: + """Create Renault entities for single vehicle.""" + entities: list[RenaultDataEntity] = [] + if "cockpit" in vehicle.coordinators: + entities.append(RenaultMileageSensor(vehicle, "Mileage")) + if vehicle.details.uses_fuel(): + entities.append(RenaultFuelAutonomySensor(vehicle, "Fuel Autonomy")) + entities.append(RenaultFuelQuantitySensor(vehicle, "Fuel Quantity")) + if "hvac_status" in vehicle.coordinators: + entities.append(RenaultOutsideTemperatureSensor(vehicle, "Outside Temperature")) + if "battery" in vehicle.coordinators: + entities.append(RenaultBatteryLevelSensor(vehicle, "Battery Level")) + entities.append(RenaultChargeStateSensor(vehicle, "Charge State")) + entities.append( + RenaultChargingRemainingTimeSensor(vehicle, "Charging Remaining Time") + ) + entities.append(RenaultChargingPowerSensor(vehicle, "Charging Power")) + entities.append(RenaultPlugStateSensor(vehicle, "Plug State")) + entities.append(RenaultBatteryAutonomySensor(vehicle, "Battery Autonomy")) + entities.append(RenaultBatteryTemperatureSensor(vehicle, "Battery Temperature")) + if "charge_mode" in vehicle.coordinators: + entities.append(RenaultChargeModeSensor(vehicle, "Charge Mode")) + return entities + + +class RenaultBatteryAutonomySensor(RenaultBatteryDataEntity, SensorEntity): + """Battery autonomy sensor.""" + + _attr_icon = "mdi:ev-station" + _attr_unit_of_measurement = LENGTH_KILOMETERS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return self.data.batteryAutonomy if self.data else None + + +class RenaultBatteryLevelSensor(RenaultBatteryDataEntity, SensorEntity): + """Battery Level sensor.""" + + _attr_device_class = DEVICE_CLASS_BATTERY + _attr_unit_of_measurement = PERCENTAGE + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return self.data.batteryLevel if self.data else None + + @property + def icon(self) -> str: + """Icon handling.""" + return icon_for_battery_level( + battery_level=self.state, charging=self.is_charging + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of this entity.""" + attrs = super().extra_state_attributes + attrs[ATTR_BATTERY_AVAILABLE_ENERGY] = ( + self.data.batteryAvailableEnergy if self.data else None + ) + return attrs + + +class RenaultBatteryTemperatureSensor(RenaultBatteryDataEntity, SensorEntity): + """Battery Temperature sensor.""" + + _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_unit_of_measurement = TEMP_CELSIUS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return self.data.batteryTemperature if self.data else None + + +class RenaultChargeModeSensor(RenaultChargeModeDataEntity, SensorEntity): + """Charge Mode sensor.""" + + _attr_device_class = DEVICE_CLASS_CHARGE_MODE + + @property + def state(self) -> str | None: + """Return the state of this entity.""" + return self.data.chargeMode if self.data else None + + @property + def icon(self) -> str: + """Icon handling.""" + if self.data and self.data.chargeMode == "schedule_mode": + return "mdi:calendar-clock" + return "mdi:calendar-remove" + + +class RenaultChargeStateSensor(RenaultBatteryDataEntity, SensorEntity): + """Charge State sensor.""" + + _attr_device_class = DEVICE_CLASS_CHARGE_STATE + + @property + def state(self) -> str | None: + """Return the state of this entity.""" + charging_status = self.data.get_charging_status() if self.data else None + return slugify(charging_status.name) if charging_status is not None else None + + @property + def icon(self) -> str: + """Icon handling.""" + return "mdi:flash" if self.is_charging else "mdi:flash-off" + + +class RenaultChargingRemainingTimeSensor(RenaultBatteryDataEntity, SensorEntity): + """Charging Remaining Time sensor.""" + + _attr_icon = "mdi:timer" + _attr_unit_of_measurement = TIME_MINUTES + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return self.data.chargingRemainingTime if self.data else None + + +class RenaultChargingPowerSensor(RenaultBatteryDataEntity, SensorEntity): + """Charging Power sensor.""" + + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_unit_of_measurement = POWER_KILO_WATT + + @property + def state(self) -> float | None: + """Return the state of this entity.""" + if not self.data or self.data.chargingInstantaneousPower is None: + return None + if self.vehicle.details.reports_charging_power_in_watts(): + # Need to convert to kilowatts + return self.data.chargingInstantaneousPower / 1000 + return self.data.chargingInstantaneousPower + + +class RenaultFuelAutonomySensor(RenaultCockpitDataEntity, SensorEntity): + """Fuel autonomy sensor.""" + + _attr_icon = "mdi:gas-station" + _attr_unit_of_measurement = LENGTH_KILOMETERS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return ( + round(self.data.fuelAutonomy) + if self.data and self.data.fuelAutonomy is not None + else None + ) + + +class RenaultFuelQuantitySensor(RenaultCockpitDataEntity, SensorEntity): + """Fuel quantity sensor.""" + + _attr_icon = "mdi:fuel" + _attr_unit_of_measurement = VOLUME_LITERS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return ( + round(self.data.fuelQuantity) + if self.data and self.data.fuelQuantity is not None + else None + ) + + +class RenaultMileageSensor(RenaultCockpitDataEntity, SensorEntity): + """Mileage sensor.""" + + _attr_icon = "mdi:sign-direction" + _attr_unit_of_measurement = LENGTH_KILOMETERS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return ( + round(self.data.totalMileage) + if self.data and self.data.totalMileage is not None + else None + ) + + +class RenaultOutsideTemperatureSensor(RenaultHVACDataEntity, SensorEntity): + """HVAC Outside Temperature sensor.""" + + _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_unit_of_measurement = TEMP_CELSIUS + + @property + def state(self) -> float | None: + """Return the state of this entity.""" + return self.data.externalTemperature if self.data else None + + +class RenaultPlugStateSensor(RenaultBatteryDataEntity, SensorEntity): + """Plug State sensor.""" + + _attr_device_class = DEVICE_CLASS_PLUG_STATE + + @property + def state(self) -> str | None: + """Return the state of this entity.""" + plug_status = self.data.get_plug_status() if self.data else None + return slugify(plug_status.name) if plug_status is not None else None + + @property + def icon(self) -> str: + """Icon handling.""" + return "mdi:power-plug" if self.is_plugged_in else "mdi:power-plug-off" diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json new file mode 100644 index 00000000000..942c8b4a06c --- /dev/null +++ b/homeassistant/components/renault/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "kamereon_no_account": "Unable to find Kamereon account." + }, + "error": { + "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon account id" + }, + "title": "Select Kamereon account id" + }, + "user": { + "data": { + "locale": "Locale", + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "title": "Set Renault credentials" + } + } + } +} diff --git a/homeassistant/components/renault/translations/en.json b/homeassistant/components/renault/translations/en.json new file mode 100644 index 00000000000..bb65493a3b3 --- /dev/null +++ b/homeassistant/components/renault/translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Account already configured", + "kamereon_no_account": "Unable to find Kamereon account." + }, + "error": { + "invalid_credentials": "Invalid credentials." + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon account id" + }, + "title": "Select Kamereon account id" + }, + "user": { + "data": { + "locale": "Locale", + "username": "Email", + "password": "Password" + }, + "title": "Set Renault credentials" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a6c97cbbab3..89b1a9ba8ae 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -216,6 +216,7 @@ FLOWS = [ "rachio", "rainmachine", "recollect_waste", + "renault", "rfxtrx", "ring", "risco", diff --git a/mypy.ini b/mypy.ini index 32800994e42..e38897bf303 100644 --- a/mypy.ini +++ b/mypy.ini @@ -902,6 +902,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.renault.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.rituals_perfume_genie.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 0acdff35fb9..bfa61df2f9e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2015,6 +2015,9 @@ raspyrfm-client==1.2.8 # homeassistant.components.rainmachine regenmaschine==3.1.5 +# homeassistant.components.renault +renault-api==0.1.4 + # homeassistant.components.python_script restrictedpython==5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b9513a7e54..7d2e39327df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1109,6 +1109,9 @@ rachiopy==1.0.3 # homeassistant.components.rainmachine regenmaschine==3.1.5 +# homeassistant.components.renault +renault-api==0.1.4 + # homeassistant.components.python_script restrictedpython==5.1 diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py new file mode 100644 index 00000000000..e4edc3b8539 --- /dev/null +++ b/tests/components/renault/__init__.py @@ -0,0 +1,159 @@ +"""Tests for the Renault integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Any +from unittest.mock import patch + +from renault_api.kamereon import models, schemas +from renault_api.renault_vehicle import RenaultVehicle + +from homeassistant.components.renault.const import ( + CONF_KAMEREON_ACCOUNT_ID, + CONF_LOCALE, + DOMAIN, +) +from homeassistant.components.renault.renault_vehicle import RenaultVehicleProxy +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import MOCK_VEHICLES + +from tests.common import MockConfigEntry, load_fixture + + +async def setup_renault_integration(hass: HomeAssistant): + """Create the Renault integration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source="user", + data={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + CONF_KAMEREON_ACCOUNT_ID: "account_id_2", + }, + unique_id="account_id_2", + options={}, + entry_id="1", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.renault.RenaultHub.attempt_login", return_value=True + ), patch("homeassistant.components.renault.RenaultHub.async_initialise"): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +def get_fixtures(vehicle_type: str) -> dict[str, Any]: + """Create a vehicle proxy for testing.""" + mock_vehicle = MOCK_VEHICLES[vehicle_type] + return { + "battery_status": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['battery_status']}") + if "battery_status" in mock_vehicle["endpoints"] + else "{}" + ).get_attributes(schemas.KamereonVehicleBatteryStatusDataSchema), + "charge_mode": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['charge_mode']}") + if "charge_mode" in mock_vehicle["endpoints"] + else "{}" + ).get_attributes(schemas.KamereonVehicleChargeModeDataSchema), + "cockpit": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['cockpit']}") + if "cockpit" in mock_vehicle["endpoints"] + else "{}" + ).get_attributes(schemas.KamereonVehicleCockpitDataSchema), + "hvac_status": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['hvac_status']}") + if "hvac_status" in mock_vehicle["endpoints"] + else "{}" + ).get_attributes(schemas.KamereonVehicleHvacStatusDataSchema), + } + + +async def create_vehicle_proxy( + hass: HomeAssistant, vehicle_type: str +) -> RenaultVehicleProxy: + """Create a vehicle proxy for testing.""" + mock_vehicle = MOCK_VEHICLES[vehicle_type] + mock_fixtures = get_fixtures(vehicle_type) + + vehicles_response: models.KamereonVehiclesResponse = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{vehicle_type}.json") + ) + ) + vehicle_details = vehicles_response.vehicleLinks[0].vehicleDetails + vehicle = RenaultVehicle( + vehicles_response.accountId, + vehicle_details.vin, + websession=aiohttp_client.async_get_clientsession(hass), + ) + + vehicle_proxy = RenaultVehicleProxy( + hass, vehicle, vehicle_details, timedelta(seconds=300) + ) + with patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.endpoint_available", + side_effect=mock_vehicle["endpoints_available"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_battery_status", + return_value=mock_fixtures["battery_status"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_charge_mode", + return_value=mock_fixtures["charge_mode"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_cockpit", + return_value=mock_fixtures["cockpit"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_hvac_status", + return_value=mock_fixtures["hvac_status"], + ): + await vehicle_proxy.async_initialise() + return vehicle_proxy + + +async def create_vehicle_proxy_with_side_effect( + hass: HomeAssistant, vehicle_type: str, side_effect: Any +) -> RenaultVehicleProxy: + """Create a vehicle proxy for testing unavailable entities.""" + mock_vehicle = MOCK_VEHICLES[vehicle_type] + + vehicles_response: models.KamereonVehiclesResponse = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{vehicle_type}.json") + ) + ) + vehicle_details = vehicles_response.vehicleLinks[0].vehicleDetails + vehicle = RenaultVehicle( + vehicles_response.accountId, + vehicle_details.vin, + websession=aiohttp_client.async_get_clientsession(hass), + ) + + vehicle_proxy = RenaultVehicleProxy( + hass, vehicle, vehicle_details, timedelta(seconds=300) + ) + with patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.endpoint_available", + side_effect=mock_vehicle["endpoints_available"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_battery_status", + side_effect=side_effect, + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_charge_mode", + side_effect=side_effect, + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_cockpit", + side_effect=side_effect, + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_hvac_status", + side_effect=side_effect, + ): + await vehicle_proxy.async_initialise() + return vehicle_proxy diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py new file mode 100644 index 00000000000..be2adafd7be --- /dev/null +++ b/tests/components/renault/const.py @@ -0,0 +1,328 @@ +"""Constants for the Renault integration tests.""" +from homeassistant.components.renault.const import ( + CONF_KAMEREON_ACCOUNT_ID, + CONF_LOCALE, + DEVICE_CLASS_CHARGE_MODE, + DEVICE_CLASS_CHARGE_STATE, + DEVICE_CLASS_PLUG_STATE, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_TEMPERATURE, + LENGTH_KILOMETERS, + PERCENTAGE, + POWER_KILO_WATT, + STATE_UNKNOWN, + TEMP_CELSIUS, + TIME_MINUTES, + VOLUME_LITERS, +) + +# Mock config data to be used across multiple tests +MOCK_CONFIG = { + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + CONF_KAMEREON_ACCOUNT_ID: "account_id_1", + CONF_LOCALE: "fr_FR", +} + +MOCK_VEHICLES = { + "zoe_40": { + "expected_device": { + "identifiers": {(DOMAIN, "VF1AAAAA555777999")}, + "manufacturer": "Renault", + "model": "Zoe", + "name": "REG-NUMBER", + "sw_version": "X101VE", + }, + "endpoints_available": [ + True, # cockpit + True, # hvac-status + True, # battery-status + True, # charge-mode + ], + "endpoints": { + "battery_status": "battery_status_charging.json", + "charge_mode": "charge_mode_always.json", + "cockpit": "cockpit_ev.json", + "hvac_status": "hvac_status.json", + }, + SENSOR_DOMAIN: [ + { + "entity_id": "sensor.battery_autonomy", + "unique_id": "vf1aaaaa555777999_battery_autonomy", + "result": "141", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.battery_level", + "unique_id": "vf1aaaaa555777999_battery_level", + "result": "60", + "unit": PERCENTAGE, + "class": DEVICE_CLASS_BATTERY, + }, + { + "entity_id": "sensor.battery_temperature", + "unique_id": "vf1aaaaa555777999_battery_temperature", + "result": "20", + "unit": TEMP_CELSIUS, + "class": DEVICE_CLASS_TEMPERATURE, + }, + { + "entity_id": "sensor.charge_mode", + "unique_id": "vf1aaaaa555777999_charge_mode", + "result": "always", + "class": DEVICE_CLASS_CHARGE_MODE, + }, + { + "entity_id": "sensor.charge_state", + "unique_id": "vf1aaaaa555777999_charge_state", + "result": "charge_in_progress", + "class": DEVICE_CLASS_CHARGE_STATE, + }, + { + "entity_id": "sensor.charging_power", + "unique_id": "vf1aaaaa555777999_charging_power", + "result": "0.027", + "unit": POWER_KILO_WATT, + "class": DEVICE_CLASS_ENERGY, + }, + { + "entity_id": "sensor.charging_remaining_time", + "unique_id": "vf1aaaaa555777999_charging_remaining_time", + "result": "145", + "unit": TIME_MINUTES, + }, + { + "entity_id": "sensor.mileage", + "unique_id": "vf1aaaaa555777999_mileage", + "result": "49114", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.outside_temperature", + "unique_id": "vf1aaaaa555777999_outside_temperature", + "result": "8.0", + "unit": TEMP_CELSIUS, + "class": DEVICE_CLASS_TEMPERATURE, + }, + { + "entity_id": "sensor.plug_state", + "unique_id": "vf1aaaaa555777999_plug_state", + "result": "plugged", + "class": DEVICE_CLASS_PLUG_STATE, + }, + ], + }, + "zoe_50": { + "expected_device": { + "identifiers": {(DOMAIN, "VF1AAAAA555777999")}, + "manufacturer": "Renault", + "model": "Zoe", + "name": "REG-NUMBER", + "sw_version": "X102VE", + }, + "endpoints_available": [ + True, # cockpit + False, # hvac-status + True, # battery-status + True, # charge-mode + ], + "endpoints": { + "battery_status": "battery_status_not_charging.json", + "charge_mode": "charge_mode_schedule.json", + "cockpit": "cockpit_ev.json", + }, + SENSOR_DOMAIN: [ + { + "entity_id": "sensor.battery_autonomy", + "unique_id": "vf1aaaaa555777999_battery_autonomy", + "result": "128", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.battery_level", + "unique_id": "vf1aaaaa555777999_battery_level", + "result": "50", + "unit": PERCENTAGE, + "class": DEVICE_CLASS_BATTERY, + }, + { + "entity_id": "sensor.battery_temperature", + "unique_id": "vf1aaaaa555777999_battery_temperature", + "result": STATE_UNKNOWN, + "unit": TEMP_CELSIUS, + "class": DEVICE_CLASS_TEMPERATURE, + }, + { + "entity_id": "sensor.charge_mode", + "unique_id": "vf1aaaaa555777999_charge_mode", + "result": "schedule_mode", + "class": DEVICE_CLASS_CHARGE_MODE, + }, + { + "entity_id": "sensor.charge_state", + "unique_id": "vf1aaaaa555777999_charge_state", + "result": "charge_error", + "class": DEVICE_CLASS_CHARGE_STATE, + }, + { + "entity_id": "sensor.charging_power", + "unique_id": "vf1aaaaa555777999_charging_power", + "result": STATE_UNKNOWN, + "unit": POWER_KILO_WATT, + "class": DEVICE_CLASS_ENERGY, + }, + { + "entity_id": "sensor.charging_remaining_time", + "unique_id": "vf1aaaaa555777999_charging_remaining_time", + "result": STATE_UNKNOWN, + "unit": TIME_MINUTES, + }, + { + "entity_id": "sensor.mileage", + "unique_id": "vf1aaaaa555777999_mileage", + "result": "49114", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.plug_state", + "unique_id": "vf1aaaaa555777999_plug_state", + "result": "unplugged", + "class": DEVICE_CLASS_PLUG_STATE, + }, + ], + }, + "captur_phev": { + "expected_device": { + "identifiers": {(DOMAIN, "VF1AAAAA555777123")}, + "manufacturer": "Renault", + "model": "Captur ii", + "name": "REG-NUMBER", + "sw_version": "XJB1SU", + }, + "endpoints_available": [ + True, # cockpit + False, # hvac-status + True, # battery-status + True, # charge-mode + ], + "endpoints": { + "battery_status": "battery_status_charging.json", + "charge_mode": "charge_mode_always.json", + "cockpit": "cockpit_fuel.json", + }, + SENSOR_DOMAIN: [ + { + "entity_id": "sensor.battery_autonomy", + "unique_id": "vf1aaaaa555777123_battery_autonomy", + "result": "141", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.battery_level", + "unique_id": "vf1aaaaa555777123_battery_level", + "result": "60", + "unit": PERCENTAGE, + "class": DEVICE_CLASS_BATTERY, + }, + { + "entity_id": "sensor.battery_temperature", + "unique_id": "vf1aaaaa555777123_battery_temperature", + "result": "20", + "unit": TEMP_CELSIUS, + "class": DEVICE_CLASS_TEMPERATURE, + }, + { + "entity_id": "sensor.charge_mode", + "unique_id": "vf1aaaaa555777123_charge_mode", + "result": "always", + "class": DEVICE_CLASS_CHARGE_MODE, + }, + { + "entity_id": "sensor.charge_state", + "unique_id": "vf1aaaaa555777123_charge_state", + "result": "charge_in_progress", + "class": DEVICE_CLASS_CHARGE_STATE, + }, + { + "entity_id": "sensor.charging_power", + "unique_id": "vf1aaaaa555777123_charging_power", + "result": "27.0", + "unit": POWER_KILO_WATT, + "class": DEVICE_CLASS_ENERGY, + }, + { + "entity_id": "sensor.charging_remaining_time", + "unique_id": "vf1aaaaa555777123_charging_remaining_time", + "result": "145", + "unit": TIME_MINUTES, + }, + { + "entity_id": "sensor.fuel_autonomy", + "unique_id": "vf1aaaaa555777123_fuel_autonomy", + "result": "35", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.fuel_quantity", + "unique_id": "vf1aaaaa555777123_fuel_quantity", + "result": "3", + "unit": VOLUME_LITERS, + }, + { + "entity_id": "sensor.mileage", + "unique_id": "vf1aaaaa555777123_mileage", + "result": "5567", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.plug_state", + "unique_id": "vf1aaaaa555777123_plug_state", + "result": "plugged", + "class": DEVICE_CLASS_PLUG_STATE, + }, + ], + }, + "captur_fuel": { + "expected_device": { + "identifiers": {(DOMAIN, "VF1AAAAA555777123")}, + "manufacturer": "Renault", + "model": "Captur ii", + "name": "REG-NUMBER", + "sw_version": "XJB1SU", + }, + "endpoints_available": [ + True, # cockpit + False, # hvac-status + # Ignore, # battery-status + # Ignore, # charge-mode + ], + "endpoints": {"cockpit": "cockpit_fuel.json"}, + SENSOR_DOMAIN: [ + { + "entity_id": "sensor.fuel_autonomy", + "unique_id": "vf1aaaaa555777123_fuel_autonomy", + "result": "35", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.fuel_quantity", + "unique_id": "vf1aaaaa555777123_fuel_quantity", + "result": "3", + "unit": VOLUME_LITERS, + }, + { + "entity_id": "sensor.mileage", + "unique_id": "vf1aaaaa555777123_mileage", + "result": "5567", + "unit": LENGTH_KILOMETERS, + }, + ], + }, +} diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py new file mode 100644 index 00000000000..c8b9c8c3e12 --- /dev/null +++ b/tests/components/renault/test_config_flow.py @@ -0,0 +1,137 @@ +"""Test the Renault config flow.""" +from unittest.mock import AsyncMock, PropertyMock, patch + +from renault_api.gigya.exceptions import InvalidCredentialsException +from renault_api.kamereon import schemas + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.renault.const import ( + CONF_KAMEREON_ACCOUNT_ID, + CONF_LOCALE, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import load_fixture + + +async def test_config_flow_single_account(hass: HomeAssistant): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + # Failed credentials + with patch( + "renault_api.renault_session.RenaultSession.login", + side_effect=InvalidCredentialsException(403042, "invalid loginID or password"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_credentials"} + + renault_account = AsyncMock() + type(renault_account).account_id = PropertyMock(return_value="account_id_1") + renault_account.get_vehicles.return_value = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture("renault/vehicle_zoe_40.json") + ) + ) + + # Account list single + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_account.RenaultAccount.account_id", return_value="123" + ), patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[renault_account], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "account_id_1" + assert result["data"][CONF_USERNAME] == "email@test.com" + assert result["data"][CONF_PASSWORD] == "test" + assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_1" + assert result["data"][CONF_LOCALE] == "fr_FR" + + +async def test_config_flow_no_account(hass: HomeAssistant): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + # Account list empty + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "homeassistant.components.renault.config_flow.RenaultHub.get_account_ids", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "kamereon_no_account" + + +async def test_config_flow_multiple_accounts(hass: HomeAssistant): + """Test what happens if multiple Kamereon accounts are available.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + # Multiple accounts + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "homeassistant.components.renault.config_flow.RenaultHub.get_account_ids", + return_value=["account_id_1", "account_id_2"], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "kamereon" + + # Account selected + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_KAMEREON_ACCOUNT_ID: "account_id_2"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "account_id_2" + assert result["data"][CONF_USERNAME] == "email@test.com" + assert result["data"][CONF_PASSWORD] == "test" + assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_2" + assert result["data"][CONF_LOCALE] == "fr_FR" diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py new file mode 100644 index 00000000000..974155c3df9 --- /dev/null +++ b/tests/components/renault/test_init.py @@ -0,0 +1,85 @@ +"""Tests for Renault setup process.""" +from unittest.mock import AsyncMock, patch + +import aiohttp +import pytest +from renault_api.gigya.exceptions import InvalidCredentialsException +from renault_api.kamereon import schemas + +from homeassistant.components.renault import ( + RenaultHub, + async_setup_entry, + async_unload_entry, +) +from homeassistant.components.renault.const import DOMAIN +from homeassistant.components.renault.renault_vehicle import RenaultVehicleProxy +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import MOCK_CONFIG + +from tests.common import MockConfigEntry, load_fixture + + +async def test_setup_unload_and_reload_entry(hass): + """Test entry setup and unload.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 + ) + renault_account = AsyncMock() + renault_account.get_vehicles.return_value = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture("renault/vehicle_zoe_40.json") + ) + ) + + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_account", + return_value=renault_account, + ): + # Set up the entry and assert that the values set during setup are where we expect + # them to be. + assert await async_setup_entry(hass, config_entry) + assert DOMAIN in hass.data and config_entry.unique_id in hass.data[DOMAIN] + assert isinstance(hass.data[DOMAIN][config_entry.unique_id], RenaultHub) + + renault_hub: RenaultHub = hass.data[DOMAIN][config_entry.unique_id] + assert len(renault_hub.vehicles) == 1 + assert isinstance( + renault_hub.vehicles["VF1AAAAA555777999"], RenaultVehicleProxy + ) + + # Unload the entry and verify that the data has been removed + assert await async_unload_entry(hass, config_entry) + assert config_entry.unique_id not in hass.data[DOMAIN] + + +async def test_setup_entry_bad_password(hass): + """Test entry setup and unload.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 + ) + + with patch( + "renault_api.renault_session.RenaultSession.login", + side_effect=InvalidCredentialsException(403042, "invalid loginID or password"), + ): + # Set up the entry and assert that the values set during setup are where we expect + # them to be. + assert not await async_setup_entry(hass, config_entry) + + +async def test_setup_entry_exception(hass): + """Test ConfigEntryNotReady when API raises an exception during entry setup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 + ) + + # In this case we are testing the condition where async_setup_entry raises + # ConfigEntryNotReady. + with patch( + "renault_api.renault_session.RenaultSession.login", + side_effect=aiohttp.ClientConnectionError, + ), pytest.raises(ConfigEntryNotReady): + assert await async_setup_entry(hass, config_entry) diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py new file mode 100644 index 00000000000..8956fa7e7e6 --- /dev/null +++ b/tests/components/renault/test_sensor.py @@ -0,0 +1,212 @@ +"""Tests for Renault sensors.""" +from unittest.mock import PropertyMock, patch + +import pytest +from renault_api.kamereon import exceptions + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.setup import async_setup_component + +from . import ( + create_vehicle_proxy, + create_vehicle_proxy_with_side_effect, + setup_renault_integration, +) +from .const import MOCK_VEHICLES + +from tests.common import mock_device_registry, mock_registry + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_sensors(hass, vehicle_type): + """Test for Renault sensors.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_proxy = await create_vehicle_proxy(hass, vehicle_type) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + assert len(device_registry.devices) == 1 + expected_device = mock_vehicle["expected_device"] + registry_entry = device_registry.async_get_device(expected_device["identifiers"]) + assert registry_entry is not None + assert registry_entry.identifiers == expected_device["identifiers"] + assert registry_entry.manufacturer == expected_device["manufacturer"] + assert registry_entry.name == expected_device["name"] + assert registry_entry.model == expected_device["model"] + assert registry_entry.sw_version == expected_device["sw_version"] + + expected_entities = mock_vehicle[SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == expected_entity["result"] + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_sensor_empty(hass, vehicle_type): + """Test for Renault sensors with empty data from Renault.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_proxy = await create_vehicle_proxy_with_side_effect(hass, vehicle_type, {}) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + assert len(device_registry.devices) == 1 + expected_device = mock_vehicle["expected_device"] + registry_entry = device_registry.async_get_device(expected_device["identifiers"]) + assert registry_entry is not None + assert registry_entry.identifiers == expected_device["identifiers"] + assert registry_entry.manufacturer == expected_device["manufacturer"] + assert registry_entry.name == expected_device["name"] + assert registry_entry.model == expected_device["model"] + assert registry_entry.sw_version == expected_device["sw_version"] + + expected_entities = mock_vehicle[SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_sensor_errors(hass, vehicle_type): + """Test for Renault sensors with temporary failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + invalid_upstream_exception = exceptions.InvalidUpstreamException( + "err.tech.500", + "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", + ) + + vehicle_proxy = await create_vehicle_proxy_with_side_effect( + hass, vehicle_type, invalid_upstream_exception + ) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + assert len(device_registry.devices) == 1 + expected_device = mock_vehicle["expected_device"] + registry_entry = device_registry.async_get_device(expected_device["identifiers"]) + assert registry_entry is not None + assert registry_entry.identifiers == expected_device["identifiers"] + assert registry_entry.manufacturer == expected_device["manufacturer"] + assert registry_entry.name == expected_device["name"] + assert registry_entry.model == expected_device["model"] + assert registry_entry.sw_version == expected_device["sw_version"] + + expected_entities = mock_vehicle[SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +async def test_sensor_access_denied(hass): + """Test for Renault sensors with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + access_denied_exception = exceptions.AccessDeniedException( + "err.func.403", + "Access is denied for this resource", + ) + + vehicle_proxy = await create_vehicle_proxy_with_side_effect( + hass, "zoe_40", access_denied_exception + ) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + assert len(device_registry.devices) == 0 + assert len(entity_registry.entities) == 0 + + +async def test_sensor_not_supported(hass): + """Test for Renault sensors with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + not_supported_exception = exceptions.NotSupportedException( + "err.tech.501", + "This feature is not technically supported by this gateway", + ) + + vehicle_proxy = await create_vehicle_proxy_with_side_effect( + hass, "zoe_40", not_supported_exception + ) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + assert len(device_registry.devices) == 0 + assert len(entity_registry.entities) == 0 diff --git a/tests/fixtures/renault/battery_status_charging.json b/tests/fixtures/renault/battery_status_charging.json new file mode 100644 index 00000000000..dbde4597e93 --- /dev/null +++ b/tests/fixtures/renault/battery_status_charging.json @@ -0,0 +1,18 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "timestamp": "2020-01-12T21:40:16Z", + "batteryLevel": 60, + "batteryTemperature": 20, + "batteryAutonomy": 141, + "batteryCapacity": 0, + "batteryAvailableEnergy": 31, + "plugStatus": 1, + "chargingStatus": 1.0, + "chargingRemainingTime": 145, + "chargingInstantaneousPower": 27 + } + } +} diff --git a/tests/fixtures/renault/battery_status_not_charging.json b/tests/fixtures/renault/battery_status_not_charging.json new file mode 100644 index 00000000000..750d0081ed9 --- /dev/null +++ b/tests/fixtures/renault/battery_status_not_charging.json @@ -0,0 +1,15 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "timestamp": "2020-11-17T09:06:48+01:00", + "batteryLevel": 50, + "batteryAutonomy": 128, + "batteryCapacity": 0, + "batteryAvailableEnergy": 0, + "plugStatus": 0, + "chargingStatus": -1.0 + } + } +} diff --git a/tests/fixtures/renault/charge_mode_always.json b/tests/fixtures/renault/charge_mode_always.json new file mode 100644 index 00000000000..6f146a2f72f --- /dev/null +++ b/tests/fixtures/renault/charge_mode_always.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { "chargeMode": "always" } + } +} diff --git a/tests/fixtures/renault/charge_mode_schedule.json b/tests/fixtures/renault/charge_mode_schedule.json new file mode 100644 index 00000000000..778994746ff --- /dev/null +++ b/tests/fixtures/renault/charge_mode_schedule.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { "chargeMode": "schedule_mode" } + } +} diff --git a/tests/fixtures/renault/cockpit_ev.json b/tests/fixtures/renault/cockpit_ev.json new file mode 100644 index 00000000000..c5a390f3dda --- /dev/null +++ b/tests/fixtures/renault/cockpit_ev.json @@ -0,0 +1,9 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "totalMileage": 49114.27 + } + } +} diff --git a/tests/fixtures/renault/cockpit_fuel.json b/tests/fixtures/renault/cockpit_fuel.json new file mode 100644 index 00000000000..575a4236c19 --- /dev/null +++ b/tests/fixtures/renault/cockpit_fuel.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777123", + "attributes": { + "fuelAutonomy": 35.0, + "fuelQuantity": 3.0, + "totalMileage": 5566.78 + } + } +} diff --git a/tests/fixtures/renault/hvac_status.json b/tests/fixtures/renault/hvac_status.json new file mode 100644 index 00000000000..f48cbae68ae --- /dev/null +++ b/tests/fixtures/renault/hvac_status.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { "externalTemperature": 8.0, "hvacStatus": "off" } + } +} diff --git a/tests/fixtures/renault/vehicle_captur_fuel.json b/tests/fixtures/renault/vehicle_captur_fuel.json new file mode 100644 index 00000000000..3aa854c61ea --- /dev/null +++ b/tests/fixtures/renault/vehicle_captur_fuel.json @@ -0,0 +1,108 @@ +{ + "accountId": "account-id-1", + "country": "LU", + "vehicleLinks": [ + { + "brand": "RENAULT", + "vin": "VF1AAAAA555777123", + "status": "ACTIVE", + "linkType": "USER", + "garageBrand": "RENAULT", + "mileage": 346, + "startDate": "2020-06-12", + "createdDate": "2020-06-12T15:02:00.555432Z", + "lastModifiedDate": "2020-06-15T06:21:43.762467Z", + "cancellationReason": {}, + "connectedDriver": { + "role": "MAIN_DRIVER", + "createdDate": "2020-06-15T06:20:39.107794Z", + "lastModifiedDate": "2020-06-15T06:20:39.107794Z" + }, + "vehicleDetails": { + "vin": "VF1AAAAA555777123", + "engineType": "H5H", + "engineRatio": "470", + "modelSCR": "CP1", + "deliveryCountry": { + "code": "BE", + "label": "BELGIQUE" + }, + "family": { + "code": "XJB", + "label": "FAMILLE B+X OVER", + "group": "007" + }, + "tcu": { + "code": "AIVCT", + "label": "AVEC BOITIER CONNECT AIVC", + "group": "E70" + }, + "navigationAssistanceLevel": { + "code": "", + "label": "", + "group": "" + }, + "battery": { + "code": "SANBAT", + "label": "SANS BATTERIE", + "group": "968" + }, + "radioType": { + "code": "NA406", + "label": "A-IVIMINDL, 2BO + 2BI + 2T, MICRO-DOUBLE, FM1/DAB+FM2", + "group": "425" + }, + "registrationCountry": { + "code": "BE" + }, + "brand": { + "label": "RENAULT" + }, + "model": { + "code": "XJB1SU", + "label": "CAPTUR II", + "group": "971" + }, + "gearbox": { + "code": "BVA7", + "label": "BOITE DE VITESSE AUTOMATIQUE 7 RAPPORTS", + "group": "427" + }, + "version": { + "code": "ITAMFHA 6TH" + }, + "energy": { + "code": "ESS", + "label": "ESSENCE", + "group": "019" + }, + "registrationNumber": "REG-NUMBER", + "vcd": "ADR00/DLIGM2/PGPRT2/FEUAR3/CDVIT1/SKTPOU/SKTPGR/SSCCPC/SSPREM/FDIU2/MAPSTD/RCALL/MET04/DANGMO/ECOMOD/SSRCAR/AIVCT/AVGSI/TPRPE/TSGNE/2TON/ITPK7/MLEXP1/SPERTA/SSPERG/SPERTP/VOLCHA/SREACT/AVOSP1/SWALBO/DWGE01/AVC1A/1234Y/AEBS07/PRAHL/AVCAM/STANDA/XJB/HJB/EA3/MF/ESS/DG/TEMP/TR4X2/AFURGE/RVDIST/ABS/SBARTO/CA02/TOPAN/PBNCH/LAC/VSTLAR/CPE/RET04/2RVLG/RALU17/CEAVRH/AIRBA2/SERIE/DRA/DRAP05/HARM01/ATAR03/SGAV02/SGAR02/BIXPE/BANAL/KM/TPRM3/AVREPL/SSDECA/SFIRBA/ABLAVI/ESPHSA/FPAS2/ALEVA/SCACBA/SOP03C/SSADPC/STHPLG/SKTGRV/VLCUIR/RETIN2/TRSEV1/REPNTC/LVAVIP/LVAREI/SASURV/KTGREP/SGACHA/BEL01/APL03/FSTPO/ALOUC5/CMAR3P/FIPOU2/NA406/BVA7/ECLHB4/RDIF10/PNSTRD/ISOFIX/ENPH01/HRGM01/SANFLT/CSRGAC/SANACF/SDPCLV/TLRP00/SPRODI/SAN613/AVFAP/AIRBDE/CHC03/E06T/SAN806/SSPTLP/SANCML/SSFLEX/SDRQAR/SEXTIN/M2019/PHAS1/SPRTQT/SAN913/STHABT/SSTYAD/HYB01/SSCABA/SANBAT/VEC012/XJB1SU/SSNBT/H5H", + "assets": [ + { + "assetType": "PICTURE", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https: //3dv2.renault.com/ImageFromBookmark?configuration=ADR00%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FCDVIT1%2FSSCCPC%2FRCALL%2FMET04%2FDANGMO%2FSSRCAR%2FAVGSI%2FITPK7%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLCHA%2FSREACT%2FDWGE01%2FAVCAM%2FHJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRVDIST%2FSBARTO%2FCA02%2FTOPAN%2FPBNCH%2FVSTLAR%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIXPE%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETIN2%2FREPNTC%2FLVAVIP%2FLVAREI%2FSGACHA%2FALOUC5%2FNA406%2FBVA7%2FECLHB4%2FRDIF10%2FCSRGAC%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB01%2FH5H&databaseId=3e814da7-766d-4039-ac69-f001a1f738c8&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE" + }, + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https: //3dv2.renault.com/ImageFromBookmark?configuration=ADR00%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FCDVIT1%2FSSCCPC%2FRCALL%2FMET04%2FDANGMO%2FSSRCAR%2FAVGSI%2FITPK7%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLCHA%2FSREACT%2FDWGE01%2FAVCAM%2FHJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRVDIST%2FSBARTO%2FCA02%2FTOPAN%2FPBNCH%2FVSTLAR%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIXPE%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETIN2%2FREPNTC%2FLVAVIP%2FLVAREI%2FSGACHA%2FALOUC5%2FNA406%2FBVA7%2FECLHB4%2FRDIF10%2FCSRGAC%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB01%2FH5H&databaseId=3e814da7-766d-4039-ac69-f001a1f738c8&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2" + } + ] + } + ], + "yearsOfMaintenance": 12, + "connectivityTechnology": "NONE", + "easyConnectStore": false, + "electrical": false, + "rlinkStore": false, + "deliveryDate": "2020-06-17", + "retrievedFromDhs": false, + "engineEnergyType": "OTHER", + "radioCode": "1234" + } + } + ] +} diff --git a/tests/fixtures/renault/vehicle_captur_phev.json b/tests/fixtures/renault/vehicle_captur_phev.json new file mode 100644 index 00000000000..03066c8238f --- /dev/null +++ b/tests/fixtures/renault/vehicle_captur_phev.json @@ -0,0 +1,110 @@ +{ + "accountId": "account-id-2", + "country": "IT", + "vehicleLinks": [ + { + "brand": "RENAULT", + "vin": "VF1AAAAA555777123", + "status": "ACTIVE", + "linkType": "OWNER", + "garageBrand": "RENAULT", + "startDate": "2020-10-07", + "createdDate": "2020-10-07T09:17:44.692802Z", + "lastModifiedDate": "2021-03-28T10:44:01.139649Z", + "ownershipStartDate": "2020-09-30", + "cancellationReason": {}, + "connectedDriver": { + "role": "MAIN_DRIVER", + "createdDate": "2020-10-08T17:36:39.445523Z", + "lastModifiedDate": "2020-10-08T17:36:39.445523Z" + }, + "vehicleDetails": { + "vin": "VF1AAAAA555777123", + "registrationDate": "2020-09-30", + "firstRegistrationDate": "2020-09-30", + "engineType": "H4M", + "engineRatio": "630", + "modelSCR": "", + "deliveryCountry": { + "code": "IT", + "label": "ITALY" + }, + "family": { + "code": "XJB", + "label": "B+X OVER FAMILY", + "group": "007" + }, + "tcu": { + "code": "AIVCT", + "label": "WITH AIVC CONNECTION UNIT", + "group": "E70" + }, + "navigationAssistanceLevel": { + "code": "", + "label": "", + "group": "" + }, + "battery": { + "code": "BT9AE1", + "label": "BATTERY BT9AE1", + "group": "968" + }, + "radioType": { + "code": "NA418", + "label": "FULL NAV DAB ETH - AUDI", + "group": "425" + }, + "registrationCountry": { + "code": "IT" + }, + "brand": { + "label": "RENAULT" + }, + "model": { + "code": "XJB1SU", + "label": "CAPTUR II", + "group": "971" + }, + "gearbox": { + "code": "BVH4", + "label": "HYBRID 4 SPEED GEARBOX", + "group": "427" + }, + "version": { + "code": "ITAMMHH 6UP" + }, + "energy": { + "code": "ESS", + "label": "PETROL", + "group": "019" + }, + "registrationNumber": "REG-NUMBER", + "vcd": "STANDA/XJB/HJB/EA3/MM/ESS/DG/TEMP/TR4X2/AFURGE/RV/ABS/SBARTO/CA02/TN/PBNCH/LAC/VT/CPE/RET04/2RVLG/RALU17/CEAVRH/AIRBA2/SERIE/DRA/DRAP05/HARM01/ATAR03/SGAV01/SGAR02/BIYPC/BANAL/KM/TPRM3/AVREPL/SSDECA/SFIRBA/ABLAVI/ESPHSA/FPAS2/ALEVA/CACBL3/SOP03C/SSADPC/STHPLG/SKTGRV/VLCUIR/RETRCR/TRSEV1/REPNTC/LVAVIP/LVAREI/SASURV/KTGREP/SGSCHA/ITA01/APL03/FSTPO/ALOUC5/PART01/CMAR3P/FIPOU2/NA418/BVH4/ECLHB4/RDIF10/PNSTRD/ISOFIX/ENPH01/HRGM01/SANFLT/CSRFLY/SANACF/SDPCLV/TLRP00/SPRODI/SAN613/AVFAP/AIRBDE/CHC03/E06U/SAN806/SSPTLP/SANCML/SSFLEX/SDRQAR/SEXTIN/M2019/PHAS1/SPRTQT/SAN913/STHABT/5DHS/HYB06/010KWH/BT9AE1/VEC237/XJB1SU/NBT018/H4M/NOADR/DLIGM2/PGPRT2/FEUAR3/SCDVIT/SKTPOU/SKTPGR/SSCCPC/SSPREM/FDIU2/MAPSTD/RCALL/MET05/SDANGM/ECOMOD/SSRCAR/AIVCT/AVGSI/TPQNW/TSGNE/2TON/ITPK4/MLEXP1/SPERTA/SSPERG/SPERTP/VOLNCH/SREACT/AVTSR1/SWALBO/DWGE01/AVC1A/VSPTA/1234Y/AEBS07/PRAHL/RRCAM", + "assets": [ + { + "assetType": "PICTURE", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=HJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRV%2FSBARTO%2FCA02%2FTN%2FPBNCH%2FVT%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIYPC%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETRCR%2FREPNTC%2FLVAVIP%2FLVAREI%2FALOUC5%2FNA418%2FBVH4%2FECLHB4%2FRDIF10%2FCSRFLY%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB06%2FH4M%2FNOADR%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FSSCCPC%2FRCALL%2FMET05%2FSDANGM%2FSSRCAR%2FAVGSI%2FITPK4%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLNCH%2FSREACT%2FDWGE01%2FRRCAM&databaseId=b2b4fefb-d131-4f8f-9a24-4223c38bc710&bookmarkSet=CARPICKER&bookmark=EXT_34_RIGHT_FRONT&profile=HELIOS_OWNERSERVICES_LARGE" + }, + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=HJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRV%2FSBARTO%2FCA02%2FTN%2FPBNCH%2FVT%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIYPC%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETRCR%2FREPNTC%2FLVAVIP%2FLVAREI%2FALOUC5%2FNA418%2FBVH4%2FECLHB4%2FRDIF10%2FCSRFLY%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB06%2FH4M%2FNOADR%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FSSCCPC%2FRCALL%2FMET05%2FSDANGM%2FSSRCAR%2FAVGSI%2FITPK4%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLNCH%2FSREACT%2FDWGE01%2FRRCAM&databaseId=b2b4fefb-d131-4f8f-9a24-4223c38bc710&bookmarkSet=CARPICKER&bookmark=EXT_34_RIGHT_FRONT&profile=HELIOS_OWNERSERVICES_SMALL_V2" + } + ] + } + ], + "yearsOfMaintenance": 12, + "connectivityTechnology": "NONE", + "easyConnectStore": false, + "electrical": false, + "rlinkStore": false, + "deliveryDate": "2020-09-30", + "retrievedFromDhs": false, + "engineEnergyType": "PHEV", + "radioCode": "1234" + } + } + ] +} diff --git a/tests/fixtures/renault/vehicle_zoe_40.json b/tests/fixtures/renault/vehicle_zoe_40.json new file mode 100644 index 00000000000..ab80d586652 --- /dev/null +++ b/tests/fixtures/renault/vehicle_zoe_40.json @@ -0,0 +1,189 @@ +{ + "accountId": "account-id-1", + "country": "FR", + "vehicleLinks": [ + { + "brand": "RENAULT", + "vin": "VF1AAAAA555777999", + "status": "ACTIVE", + "linkType": "OWNER", + "garageBrand": "RENAULT", + "annualMileage": 16000, + "mileage": 26464, + "startDate": "2017-08-07", + "createdDate": "2019-05-23T21:38:16.409008Z", + "lastModifiedDate": "2020-11-17T08:41:40.497400Z", + "ownershipStartDate": "2017-08-01", + "cancellationReason": {}, + "connectedDriver": { + "role": "MAIN_DRIVER", + "createdDate": "2019-06-17T09:49:06.880627Z", + "lastModifiedDate": "2019-06-17T09:49:06.880627Z" + }, + "vehicleDetails": { + "vin": "VF1AAAAA555777999", + "registrationDate": "2017-08-01", + "firstRegistrationDate": "2017-08-01", + "engineType": "5AQ", + "engineRatio": "601", + "modelSCR": "ZOE", + "deliveryCountry": { + "code": "FR", + "label": "FRANCE" + }, + "family": { + "code": "X10", + "label": "FAMILLE X10", + "group": "007" + }, + "tcu": { + "code": "TCU0G2", + "label": "TCU VER 0 GEN 2", + "group": "E70" + }, + "navigationAssistanceLevel": { + "code": "NAV3G5", + "label": "LEVEL 3 TYPE 5 NAVIGATION", + "group": "408" + }, + "battery": { + "code": "BT4AR1", + "label": "BATTERIE BT4AR1", + "group": "968" + }, + "radioType": { + "code": "RAD37A", + "label": "RADIO 37A", + "group": "425" + }, + "registrationCountry": { + "code": "FR" + }, + "brand": { + "label": "RENAULT" + }, + "model": { + "code": "X101VE", + "label": "ZOE", + "group": "971" + }, + "gearbox": { + "code": "BVEL", + "label": "BOITE A VARIATEUR ELECTRIQUE", + "group": "427" + }, + "version": { + "code": "INT MB 10R" + }, + "energy": { + "code": "ELEC", + "label": "ELECTRIQUE", + "group": "019" + }, + "registrationNumber": "REG-NUMBER", + "vcd": "SYTINC/SKTPOU/SAND41/FDIU1/SSESM/MAPSUP/SSCALL/SAND88/SAND90/SQKDRO/SDIFPA/FACBA2/PRLEX1/SSRCAR/CABDO2/TCU0G2/SWALBO/EVTEC1/STANDA/X10/B10/EA2/MB/ELEC/DG/TEMP/TR4X2/RV/ABS/CAREG/LAC/VT003/CPE/RET03/SPROJA/RALU16/CEAVRH/AIRBA1/SERIE/DRA/DRAP08/HARM02/ATAR/TERQG/SFBANA/KM/DPRPN/AVREPL/SSDECA/ASRESP/RDAR02/ALEVA/CACBL2/SOP02C/CTHAB2/TRNOR/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/APL03/ALOUCC/CMAR3P/NAV3G5/RAD37A/BVEL/AUTAUG/RNORM/ISOFIX/EQPEUR/HRGM01/SDPCLV/TLFRAN/SPRODI/SAN613/SSAPEX/GENEV1/ELC1/SANCML/PE2012/PHAS1/SAN913/045KWH/BT4AR1/VEC153/X101VE/NBT017/5AQ", + "assets": [ + { + "assetType": "PICTURE", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE" + }, + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2" + } + ] + }, + { + "assetType": "PDF", + "assetRole": "GUIDE", + "title": "PDF Guide", + "description": "", + "renditions": [ + { + "url": "https://cdn.group.renault.com/ren/gb/myr/assets/x101ve/manual.pdf.asset.pdf/1558704861676.pdf" + } + ] + }, + { + "assetType": "URL", + "assetRole": "GUIDE", + "title": "e-guide", + "description": "", + "renditions": [ + { + "url": "http://gb.e-guide.renault.com/eng/Zoe" + } + ] + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "10 Fundamentals about getting the best out of your electric vehicle", + "description": "", + "renditions": [ + { + "url": "39r6QEKcOM4" + } + ] + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "Automatic Climate Control", + "description": "", + "renditions": [ + { + "url": "Va2FnZFo_GE" + } + ] + }, + { + "assetType": "URL", + "assetRole": "CAR", + "title": "More videos", + "description": "", + "renditions": [ + { + "url": "https://www.youtube.com/watch?v=wfpCMkK1rKI" + } + ] + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "Charging the battery", + "description": "", + "renditions": [ + { + "url": "RaEad8DjUJs" + } + ] + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "Charging the battery at a station with a flap", + "description": "", + "renditions": [ + { + "url": "zJfd7fJWtr0" + } + ] + } + ], + "yearsOfMaintenance": 12, + "connectivityTechnology": "RLINK1", + "easyConnectStore": false, + "electrical": true, + "rlinkStore": false, + "deliveryDate": "2017-08-11", + "retrievedFromDhs": false, + "engineEnergyType": "ELEC", + "radioCode": "1234" + } + } + ] +} diff --git a/tests/fixtures/renault/vehicle_zoe_50.json b/tests/fixtures/renault/vehicle_zoe_50.json new file mode 100644 index 00000000000..560b2a2246a --- /dev/null +++ b/tests/fixtures/renault/vehicle_zoe_50.json @@ -0,0 +1,161 @@ +{ + "country": "GB", + "vehicleLinks": [ + { + "preferredDealer": { + "brand": "RENAULT", + "createdDate": "2019-05-23T20:42:01.086661Z", + "lastModifiedDate": "2019-05-23T20:42:01.086662Z", + "dealerId": "dealer-id-1" + }, + "garageBrand": "RENAULT", + "vehicleDetails": { + "assets": [ + { + "assetType": "PICTURE", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=DLIGM2%2FKITPOU%2FDANGMO%2FITPK4%2FVOLNCH%2FREACTI%2FSSAEBS%2FPRAHL%2FRRCAM%2FX10%2FB10%2FEA3%2FDG%2FCAREG%2FVSTLAR%2FRET03%2FPROJAB%2FRALU16%2FDRAP13%2F3ATRPH%2FTELNJ%2FALEVA%2FVLCUIR%2FRETRCR%2FRETC%2FLVAREL%2FSGSCHA%2FNA418%2FRDIF01%2FTL01A%2FNBT022&databaseId=a864e752-b1b9-405e-9c3e-880073e36cc9&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE" + }, + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=DLIGM2%2FKITPOU%2FDANGMO%2FITPK4%2FVOLNCH%2FREACTI%2FSSAEBS%2FPRAHL%2FRRCAM%2FX10%2FB10%2FEA3%2FDG%2FCAREG%2FVSTLAR%2FRET03%2FPROJAB%2FRALU16%2FDRAP13%2F3ATRPH%2FTELNJ%2FALEVA%2FVLCUIR%2FRETRCR%2FRETC%2FLVAREL%2FSGSCHA%2FNA418%2FRDIF01%2FTL01A%2FNBT022&databaseId=a864e752-b1b9-405e-9c3e-880073e36cc9&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2" + } + ] + }, + { + "title": "PDF Guide", + "description": "", + "assetType": "PDF", + "assetRole": "GUIDE", + "renditions": [ + { + "url": "https://cdn.group.renault.com/ren/gb/myr/assets/x102ve/manual.pdf.asset.pdf/1558696740707.pdf" + } + ] + }, + { + "title": "e-guide", + "description": "", + "assetType": "URL", + "assetRole": "GUIDE", + "renditions": [ + { + "url": "https://gb.e-guide.renault.com/eng/Zoe-ph2" + } + ] + }, + { + "title": "All-New ZOE: Welcome to your new car", + "description": "", + "assetType": "VIDEO", + "assetRole": "CAR", + "renditions": [ + { + "url": "1OGwwmWHB6o" + } + ] + }, + { + "title": "Renault ZOE: All you need to know", + "description": "", + "assetType": "VIDEO", + "assetRole": "CAR", + "renditions": [ + { + "url": "_BVH-Rd6e5I" + } + ] + } + ], + "engineType": "5AQ", + "registrationCountry": { + "code": "FR" + }, + "radioType": { + "group": "425", + "code": "NA418", + "label": " FULL NAV DAB ETH - AUDI" + }, + "tcu": { + "group": "E70", + "code": "AIVCT", + "label": "AVEC BOITIER CONNECT AIVC" + }, + "brand": { + "label": "RENAULT" + }, + "deliveryDate": "2020-01-22", + "engineEnergyType": "ELEC", + "registrationDate": "2020-01-13", + "gearbox": { + "group": "427", + "code": "BVEL", + "label": "BOITE A VARIATEUR ELECTRIQUE" + }, + "model": { + "group": "971", + "code": "X102VE", + "label": "ZOE" + }, + "electrical": true, + "energy": { + "group": "019", + "code": "ELEC", + "label": "ELECTRIQUE" + }, + "navigationAssistanceLevel": { + "group": "408", + "code": "SAN408", + "label": "CRITERE DE CONTEXTE" + }, + "yearsOfMaintenance": 12, + "rlinkStore": false, + "radioCode": "1234", + "registrationNumber": "REG-NUMBER", + "modelSCR": "ZOE", + "easyConnectStore": false, + "engineRatio": "605", + "battery": { + "group": "968", + "code": "BT4AR1", + "label": "BATTERIE BT4AR1" + }, + "vin": "VF1AAAAA555777999", + "retrievedFromDhs": false, + "vcd": "ASCOD0/DLIGM2/SSTINC/KITPOU/SKTPGR/SSCCPC/SDPSEC/FDIU2/SSMAP/SSCALL/FACBA1/DANGMO/SSRCAR/SSCABD/AIVCT/AVGSI/ITPK4/VOLNCH/REACTI/AVOSP1/SWALBO/SSDWGE/1234Y/SSAEBS/PRAHL/RRCAM/STANDA/X10/B10/EA3/MD/ELEC/DG/TEMP/TR4X2/AFURGE/RV/ABS/CAREG/LAC/VSTLAR/CPETIR/RET03/PROJAB/RALU16/CEAVRH/ADAC/AIRBA2/SERIE/DRA/DRAP13/HARM02/3ATRPH/SGAV01/BARRAB/TELNJ/SFBANA/KM/DPRPN/AVREPL/SSDECA/ABLAV/ASRESP/ALEVA/SCACBA/SOP02C/STHPLG/SKTGRV/VLCUIR/RETRCR/TRSEV1/RETC/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/FRA01/APL03/FSTPO/ALOUC5/CMAR3P/SAN408/NA418/BVEL/AUTAUG/SPREST/RDIF01/ISOFIX/EQPEUR/HRGM01/SDPCLV/CHASTD/TL01A/SPRODI/SAN613/AIRBDE/PSMREC/ELC1/SSPTLP/SANCML/SEXTIN/PE2019/PHAS2/SAN913/THABT2/SSTYAD/SSHYB/052KWH/BT4AR1/VEC018/X102VE/NBT022/5AQ", + "firstRegistrationDate": "2020-01-13", + "deliveryCountry": { + "code": "FR", + "label": "FRANCE" + }, + "connectivityTechnology": "RLINK1", + "family": { + "group": "007", + "code": "X10", + "label": "FAMILLE X10" + }, + "version": { + "code": "INT A MD 1L" + } + }, + "status": "ACTIVE", + "createdDate": "2020-08-21T16:48:00.243967Z", + "cancellationReason": {}, + "linkType": "OWNER", + "connectedDriver": { + "role": "MAIN_DRIVER", + "lastModifiedDate": "2020-08-22T09:41:53.477398Z", + "createdDate": "2020-08-22T09:41:53.477398Z" + }, + "vin": "VF1AAAAA555777999", + "lastModifiedDate": "2020-11-29T22:01:21.162572Z", + "brand": "RENAULT", + "startDate": "2020-08-21", + "ownershipStartDate": "2020-01-13", + "ownershipEndDate": "2020-08-21" + } + ], + "accountId": "account-id-1" +}