Add renault integration (#39605)

This commit is contained in:
epenet 2021-07-28 21:41:11 +02:00 committed by GitHub
parent 7bd46b7705
commit 8d84edd3b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 2478 additions and 0 deletions

View File

@ -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.*

View File

@ -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

View File

@ -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

View File

@ -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)}
),
)

View File

@ -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"

View File

@ -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"
}

View File

@ -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)

View File

@ -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")

View File

@ -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

View File

@ -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()

View File

@ -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"

View File

@ -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"
}
}
}
}

View File

@ -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"
}
}
}
}

View File

@ -216,6 +216,7 @@ FLOWS = [
"rachio",
"rainmachine",
"recollect_waste",
"renault",
"rfxtrx",
"ring",
"risco",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
},
],
},
}

View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -0,0 +1,7 @@
{
"data": {
"type": "Car",
"id": "VF1AAAAA555777999",
"attributes": { "chargeMode": "always" }
}
}

View File

@ -0,0 +1,7 @@
{
"data": {
"type": "Car",
"id": "VF1AAAAA555777999",
"attributes": { "chargeMode": "schedule_mode" }
}
}

View File

@ -0,0 +1,9 @@
{
"data": {
"type": "Car",
"id": "VF1AAAAA555777999",
"attributes": {
"totalMileage": 49114.27
}
}
}

View File

@ -0,0 +1,11 @@
{
"data": {
"type": "Car",
"id": "VF1AAAAA555777123",
"attributes": {
"fuelAutonomy": 35.0,
"fuelQuantity": 3.0,
"totalMileage": 5566.78
}
}
}

View File

@ -0,0 +1,7 @@
{
"data": {
"type": "Car",
"id": "VF1AAAAA555777999",
"attributes": { "externalTemperature": 8.0, "hvacStatus": "off" }
}
}

View File

@ -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"
}
}
]
}

View File

@ -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"
}
}
]
}

View File

@ -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"
}
}
]
}

View File

@ -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"
}