-
-
Notifications
You must be signed in to change notification settings - Fork 36.9k
Add renault integration #39605
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add renault integration #39605
Changes from all commits
22771c6
7fc14ab
3526a59
dfc0117
3d85794
d03a537
b3cfbbd
eae18d7
9c9656c
9d8741f
39dc15b
a71239a
0c997d0
829d152
d57c079
18da0f7
3061af6
59d99b8
d0689d1
b4e6e69
966f9ca
02613f5
d4e9092
6ba42e6
1c5c889
6be83e4
732df47
59185a2
9c370b3
60c101e
ef38fd0
eb04d22
8021c9c
3139401
57a9c5e
2bebbd2
5f44f48
2b6872a
f06dc13
9f9e5d5
0fee272
a2141cd
fd07d1f
bd43569
fc50d35
e92d85a
61c9f57
c2f86e6
eae1f5b
fc8ae7c
2490f9f
9990345
a1aaae7
e22529b
43b401b
27faa55
4feefde
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()), | ||
epenet marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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)} | ||
| ), | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
frenck marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """ | ||
| await self._async_refresh(log_failures=False, raise_on_auth_failed=True) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this necessary? States already have last_changed and last_updated attributes
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @balloob this is the last time that the Renault vehicle talked to the Renault servers. It is part of the response from the Renault API. |
||
|
|
||
| @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") | ||
Uh oh!
There was an error while loading. Please reload this page.