Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
22771c6
Add new component renault
epenet Aug 31, 2020
7fc14ab
Add tests for config_flow
epenet Sep 3, 2020
3526a59
Fix isort, flake8, requirements
epenet Sep 3, 2020
dfc0117
Review grab of API keys
epenet Sep 4, 2020
3d85794
Review grab of API keys
epenet Sep 4, 2020
d03a537
Request Gigya and Kamereon API keys in the config flow.
epenet Sep 7, 2020
b3cfbbd
Update to .coveragerc
epenet Sep 8, 2020
eae18d7
Logging tweaks
epenet Sep 8, 2020
9c9656c
Fix pyzeproxy initialisation methods
epenet Oct 2, 2020
9d8741f
Sort PyzeVehicleProxy properties in alphabetical order
epenet Oct 2, 2020
39dc15b
Update strings.json with common references
epenet Oct 2, 2020
a71239a
Rename PyZEProxy, PyZEVehicleProxy, and implement recommendations fro…
epenet Oct 25, 2020
0c997d0
Cleanup
epenet Oct 31, 2020
829d152
Fix property versus method
epenet Oct 31, 2020
d57c079
Move from pyze to renault-api
epenet Dec 3, 2020
18da0f7
Update strings
epenet Dec 3, 2020
3061af6
Bump renault-api version
epenet Mar 4, 2021
59d99b8
Align with custom component
epenet Mar 4, 2021
d0689d1
Adjust tests
epenet Mar 4, 2021
b4e6e69
Fix isort
epenet Mar 4, 2021
966f9ca
Implement changes from milanmeu
epenet Apr 23, 2021
02613f5
Update .coveragerc
epenet Apr 23, 2021
d4e9092
Optimise async_unload_entry
epenet Apr 26, 2021
6ba42e6
Update error descriptions
epenet Apr 26, 2021
1c5c889
Update type hints
epenet Apr 26, 2021
6be83e4
Rename FlowResultDict to FlowResult
epenet Apr 29, 2021
732df47
Update load/unload methods
epenet Apr 29, 2021
59185a2
Fix mypy
epenet Apr 29, 2021
9c370b3
Implement strict typing
epenet Apr 29, 2021
60c101e
Tidy up cast
epenet Apr 30, 2021
ef38fd0
Update generic type
epenet Apr 30, 2021
eb04d22
Update typing for DeviceInfo
epenet May 3, 2021
8021c9c
Drop CONNECTION_CLASS
epenet May 3, 2021
3139401
More strict-typing amends
epenet May 3, 2021
57a9c5e
Beef up the sensors
epenet May 17, 2021
2bebbd2
Improve test coverage
epenet May 17, 2021
5f44f48
Bump renault-api to 0.1.4
epenet May 17, 2021
2b6872a
Improve test coverage
epenet May 17, 2021
f06dc13
Add tests for exception handling
epenet May 17, 2021
9f9e5d5
Add sensor tests
epenet May 17, 2021
0fee272
Complete test coverage
epenet May 17, 2021
a2141cd
Adjust dictionary loop
epenet Jun 15, 2021
fd07d1f
Adjust dictionary loop
epenet Jun 15, 2021
bd43569
Update endpoint checks during initialisation
epenet Jun 15, 2021
fc50d35
Adjust loop
epenet Jul 22, 2021
e92d85a
Cleanup available property
epenet Jul 22, 2021
61c9f57
Cleanup extra_state_attributes property
epenet Jul 22, 2021
c2f86e6
Tidy up python code
epenet Jul 22, 2021
eae1f5b
Update default entity name
epenet Jul 22, 2021
fc8ae7c
Use generics
epenet Jul 22, 2021
2490f9f
Cleanup extra_state_attributes property
epenet Jul 22, 2021
9990345
Extent Sensors from SensorEntity
epenet Jul 22, 2021
a1aaae7
Use entity class attributes
epenet Jul 22, 2021
e22529b
Fix isort
epenet Jul 22, 2021
43b401b
Updates for test coverage
epenet Jul 22, 2021
27faa55
Fix pyupgrade
epenet Jul 22, 2021
4feefde
Adjust DeviceInfo
epenet Jul 23, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,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.*
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions homeassistant/components/renault/__init__.py
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
92 changes: 92 additions & 0 deletions homeassistant/components/renault/config_flow.py
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()),
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)}
),
)
15 changes: 15 additions & 0 deletions homeassistant/components/renault/const.py
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"
13 changes: 13 additions & 0 deletions homeassistant/components/renault/manifest.json
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"
}
72 changes: 72 additions & 0 deletions homeassistant/components/renault/renault_coordinator.py
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.
"""
await self._async_refresh(log_failures=False, raise_on_auth_failed=True)
103 changes: 103 additions & 0 deletions homeassistant/components/renault/renault_entities.py
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}
Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.
It serves to highlight issues where the HA component is working normally, the Renault servers are working correctly, but the car itself has stopped sending updates.


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