Add RDW Vehicle information integration (#59240)

This commit is contained in:
Franck Nijhof
2021-11-06 19:34:51 +01:00
committed by GitHub
parent fc7d4ed118
commit fdf1bfa140
19 changed files with 595 additions and 0 deletions

View File

@ -96,6 +96,7 @@ homeassistant.components.persistent_notification.*
homeassistant.components.pi_hole.*
homeassistant.components.proximity.*
homeassistant.components.rainmachine.*
homeassistant.components.rdw.*
homeassistant.components.recollect_waste.*
homeassistant.components.recorder.purge
homeassistant.components.recorder.repack

View File

@ -425,6 +425,7 @@ homeassistant/components/raincloud/* @vanstinator
homeassistant/components/rainforest_eagle/* @gtdiehl @jcalbert
homeassistant/components/rainmachine/* @bachya
homeassistant/components/random/* @fabaff
homeassistant/components/rdw/* @frenck
homeassistant/components/recollect_waste/* @bachya
homeassistant/components/recorder/* @home-assistant/core
homeassistant/components/rejseplanen/* @DarkFox

View File

@ -0,0 +1,42 @@
"""Support for RDW."""
from __future__ import annotations
from vehicle import RDW, Vehicle
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_LICENSE_PLATE, DOMAIN, LOGGER, SCAN_INTERVAL
PLATFORMS = (SENSOR_DOMAIN,)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up RDW from a config entry."""
session = async_get_clientsession(hass)
rdw = RDW(session=session, license_plate=entry.data[CONF_LICENSE_PLATE])
coordinator: DataUpdateCoordinator[Vehicle] = DataUpdateCoordinator(
hass,
LOGGER,
name=f"{DOMAIN}_APK",
update_interval=SCAN_INTERVAL,
update_method=rdw.vehicle,
)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload RDW config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
del hass.data[DOMAIN][entry.entry_id]
return unload_ok

View File

@ -0,0 +1,56 @@
"""Config flow to configure the RDW integration."""
from __future__ import annotations
from typing import Any
from vehicle import RDW, RDWError, RDWUnknownLicensePlateError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_LICENSE_PLATE, DOMAIN
class RDWFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for RDW."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
session = async_get_clientsession(self.hass)
rdw = RDW(session=session)
try:
vehicle = await rdw.vehicle(
license_plate=user_input[CONF_LICENSE_PLATE]
)
except RDWUnknownLicensePlateError:
errors["base"] = "unknown_license_plate"
except RDWError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(vehicle.license_plate)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_LICENSE_PLATE],
data={
CONF_LICENSE_PLATE: vehicle.license_plate,
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_LICENSE_PLATE): str,
}
),
errors=errors,
)

View File

@ -0,0 +1,14 @@
"""Constants for the RDW integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Final
DOMAIN: Final = "rdw"
LOGGER = logging.getLogger(__package__)
SCAN_INTERVAL = timedelta(hours=1)
ENTRY_TYPE_SERVICE: Final = "service"
CONF_LICENSE_PLATE: Final = "license_plate"

View File

@ -0,0 +1,10 @@
{
"domain": "rdw",
"name": "RDW",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rdw",
"requirements": ["vehicle==0.1.0"],
"codeowners": ["@frenck"],
"quality_scale": "platinum",
"iot_class": "cloud_polling"
}

View File

@ -0,0 +1,103 @@
"""Support for RDW sensors."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from vehicle import Vehicle
from homeassistant.components.sensor import (
DEVICE_CLASS_DATE,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import CONF_LICENSE_PLATE, DOMAIN, ENTRY_TYPE_SERVICE
@dataclass
class RDWSensorEntityDescriptionMixin:
"""Mixin for required keys."""
value_fn: Callable[[Vehicle], str | float | None]
@dataclass
class RDWSensorEntityDescription(
SensorEntityDescription, RDWSensorEntityDescriptionMixin
):
"""Describes RDW sensor entity."""
SENSORS: tuple[RDWSensorEntityDescription, ...] = (
RDWSensorEntityDescription(
key="apk_expiration",
name="APK Expiration",
device_class=DEVICE_CLASS_DATE,
value_fn=lambda vehicle: vehicle.apk_expiration.isoformat(),
),
RDWSensorEntityDescription(
key="name_registration_date",
name="Name Registration Date",
device_class=DEVICE_CLASS_DATE,
value_fn=lambda vehicle: vehicle.name_registration_date.isoformat(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up RDW sensors based on a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
RDWSensorEntity(
coordinator=coordinator,
license_plate=entry.data[CONF_LICENSE_PLATE],
description=description,
)
for description in SENSORS
)
class RDWSensorEntity(CoordinatorEntity, SensorEntity):
"""Defines an RDW sensor."""
entity_description: RDWSensorEntityDescription
def __init__(
self,
*,
coordinator: DataUpdateCoordinator,
license_plate: str,
description: RDWSensorEntityDescription,
) -> None:
"""Initialize RDW sensor."""
super().__init__(coordinator=coordinator)
self.entity_description = description
self._attr_unique_id = f"{license_plate}_{description.key}"
self._attr_device_info = DeviceInfo(
entry_type=ENTRY_TYPE_SERVICE,
identifiers={(DOMAIN, f"{license_plate}")},
manufacturer=coordinator.data.brand,
name=f"{coordinator.data.brand}: {coordinator.data.license_plate}",
model=coordinator.data.model,
configuration_url=f"https://ovi.rdw.nl/default.aspx?kenteken={coordinator.data.license_plate}",
)
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -0,0 +1,15 @@
{
"config": {
"step": {
"user": {
"data": {
"license_plate": "License plate"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown_license_plate": "Unknown license plate"
}
}
}

View File

@ -0,0 +1,15 @@
{
"config": {
"error": {
"cannot_connect": "Failed to connect",
"unknown_license_plate": "Unknown license plate"
},
"step": {
"user": {
"data": {
"license_plate": "License plate"
}
}
}
}
}

View File

@ -235,6 +235,7 @@ FLOWS = [
"rachio",
"rainforest_eagle",
"rainmachine",
"rdw",
"recollect_waste",
"renault",
"rfxtrx",

View File

@ -1067,6 +1067,17 @@ no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.rdw.*]
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.recollect_waste.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -2365,6 +2365,9 @@ uvcclient==0.11.0
# homeassistant.components.vallox
vallox-websocket-api==2.8.1
# homeassistant.components.rdw
vehicle==0.1.0
# homeassistant.components.velbus
velbus-aio==2021.11.0

View File

@ -1372,6 +1372,9 @@ url-normalize==1.4.1
# homeassistant.components.uvc
uvcclient==0.11.0
# homeassistant.components.rdw
vehicle==0.1.0
# homeassistant.components.velbus
velbus-aio==2021.11.0

View File

@ -0,0 +1 @@
"""Tests for the RDW integration."""

View File

@ -0,0 +1,69 @@
"""Fixtures for RDW integration tests."""
from __future__ import annotations
from collections.abc import Generator
from unittest.mock import MagicMock, patch
import pytest
from vehicle import Vehicle
from homeassistant.components.rdw.const import CONF_LICENSE_PLATE, DOMAIN
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_fixture
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="My Car",
domain=DOMAIN,
data={CONF_LICENSE_PLATE: "11ZKZ3"},
unique_id="11ZKZ3",
)
@pytest.fixture
def mock_setup_entry() -> Generator[None, None, None]:
"""Mock setting up a config entry."""
with patch("homeassistant.components.rdw.async_setup_entry", return_value=True):
yield
@pytest.fixture
def mock_rdw_config_flow() -> Generator[None, MagicMock, None]:
"""Return a mocked RDW client."""
with patch(
"homeassistant.components.rdw.config_flow.RDW", autospec=True
) as rdw_mock:
rdw = rdw_mock.return_value
rdw.vehicle.return_value = Vehicle.parse_raw(load_fixture("rdw/11ZKZ3.json"))
yield rdw
@pytest.fixture
def mock_rdw(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
"""Return a mocked WLED client."""
fixture: str = "rdw/11ZKZ3.json"
if hasattr(request, "param") and request.param:
fixture = request.param
vehicle = Vehicle.parse_raw(load_fixture(fixture))
with patch("homeassistant.components.rdw.RDW", autospec=True) as rdw_mock:
rdw = rdw_mock.return_value
rdw.vehicle.return_value = vehicle
yield rdw
@pytest.fixture
async def init_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_rdw: MagicMock
) -> MockConfigEntry:
"""Set up the RDW integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View File

@ -0,0 +1,52 @@
{
"kenteken": "11ZKZ3",
"voertuigsoort": "Personenauto",
"merk": "SKODA",
"handelsbenaming": "CITIGO",
"vervaldatum_apk": "20220104",
"datum_tenaamstelling": "20211104",
"inrichting": "hatchback",
"aantal_zitplaatsen": "4",
"eerste_kleur": "GRIJS",
"tweede_kleur": "Niet geregistreerd",
"aantal_cilinders": "3",
"cilinderinhoud": "999",
"massa_ledig_voertuig": "840",
"toegestane_maximum_massa_voertuig": "1290",
"massa_rijklaar": "940",
"zuinigheidslabel": "A",
"datum_eerste_toelating": "20130104",
"datum_eerste_afgifte_nederland": "20130104",
"wacht_op_keuren": "Geen verstrekking in Open Data",
"catalogusprijs": "10697",
"wam_verzekerd": "Nee",
"aantal_deuren": "0",
"aantal_wielen": "4",
"afstand_hart_koppeling_tot_achterzijde_voertuig": "0",
"afstand_voorzijde_voertuig_tot_hart_koppeling": "0",
"lengte": "356",
"breedte": "0",
"europese_voertuigcategorie": "M1",
"plaats_chassisnummer": "r. motorruimte",
"technische_max_massa_voertuig": "1290",
"type": "AA",
"typegoedkeuringsnummer": "e13*2007/46*1169*05",
"variant": "ABCHYA",
"uitvoering": "FM5FM5CF0037MGVR2N1FA1SK",
"volgnummer_wijziging_eu_typegoedkeuring": "0",
"vermogen_massarijklaar": "0.05",
"wielbasis": "241",
"export_indicator": "Nee",
"openstaande_terugroepactie_indicator": "Nee",
"maximum_massa_samenstelling": "0",
"aantal_rolstoelplaatsen": "0",
"jaar_laatste_registratie_tellerstand": "2021",
"tellerstandoordeel": "Logisch",
"code_toelichting_tellerstandoordeel": "00",
"tenaamstellen_mogelijk": "Ja",
"api_gekentekende_voertuigen_assen": "https://opendata.rdw.nl/resource/3huj-srit.json",
"api_gekentekende_voertuigen_brandstof": "https://opendata.rdw.nl/resource/8ys7-d773.json",
"api_gekentekende_voertuigen_carrosserie": "https://opendata.rdw.nl/resource/vezc-m2t6.json",
"api_gekentekende_voertuigen_carrosserie_specifiek": "https://opendata.rdw.nl/resource/jhie-znh9.json",
"api_gekentekende_voertuigen_voertuigklasse": "https://opendata.rdw.nl/resource/kmfi-hrps.json"
}

View File

@ -0,0 +1,92 @@
"""Tests for the RDW config flow."""
from unittest.mock import MagicMock
from vehicle.exceptions import RDWConnectionError, RDWUnknownLicensePlateError
from homeassistant.components.rdw.const import CONF_LICENSE_PLATE, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM
async def test_full_user_flow(
hass: HomeAssistant, mock_rdw_config_flow: MagicMock, mock_setup_entry: MagicMock
) -> None:
"""Test the full user configuration flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result.get("type") == RESULT_TYPE_FORM
assert result.get("step_id") == SOURCE_USER
assert "flow_id" in result
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LICENSE_PLATE: "11-ZKZ-3",
},
)
assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY
assert result2.get("title") == "11-ZKZ-3"
assert result2.get("data") == {CONF_LICENSE_PLATE: "11ZKZ3"}
async def test_full_flow_with_authentication_error(
hass: HomeAssistant, mock_rdw_config_flow: MagicMock, mock_setup_entry: MagicMock
) -> None:
"""Test the full user configuration flow with incorrect license plate.
This tests tests a full config flow, with a case the user enters an invalid
license plate, but recover by entering the correct one.
"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result.get("type") == RESULT_TYPE_FORM
assert result.get("step_id") == SOURCE_USER
assert "flow_id" in result
mock_rdw_config_flow.vehicle.side_effect = RDWUnknownLicensePlateError
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LICENSE_PLATE: "0001TJ",
},
)
assert result2.get("type") == RESULT_TYPE_FORM
assert result2.get("step_id") == SOURCE_USER
assert result2.get("errors") == {"base": "unknown_license_plate"}
assert "flow_id" in result2
mock_rdw_config_flow.vehicle.side_effect = None
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
user_input={
CONF_LICENSE_PLATE: "11-ZKZ-3",
},
)
assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY
assert result3.get("title") == "11-ZKZ-3"
assert result3.get("data") == {CONF_LICENSE_PLATE: "11ZKZ3"}
async def test_connection_error(
hass: HomeAssistant, mock_rdw_config_flow: MagicMock
) -> None:
"""Test API connection error."""
mock_rdw_config_flow.vehicle.side_effect = RDWConnectionError
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={CONF_LICENSE_PLATE: "0001TJ"},
)
assert result.get("type") == RESULT_TYPE_FORM
assert result.get("errors") == {"base": "cannot_connect"}

View File

@ -0,0 +1,45 @@
"""Tests for the RDW integration."""
from unittest.mock import AsyncMock, MagicMock, patch
from homeassistant.components.rdw.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_load_unload_config_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_rdw: AsyncMock,
) -> None:
"""Test the RDW configuration entry loading/unloading."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert not hass.data.get(DOMAIN)
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@patch(
"homeassistant.components.rdw.RDW.vehicle",
side_effect=RuntimeError,
)
async def test_config_entry_not_ready(
mock_request: MagicMock,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the RDW configuration entry not ready."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_request.call_count == 1
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@ -0,0 +1,61 @@
"""Tests for the sensors provided by the RDW integration."""
from homeassistant.components.rdw.const import DOMAIN, ENTRY_TYPE_SERVICE
from homeassistant.components.sensor import ATTR_STATE_CLASS
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_FRIENDLY_NAME,
ATTR_ICON,
ATTR_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_DATE,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry
async def test_vehicle_sensors(
hass: HomeAssistant,
init_integration: MockConfigEntry,
) -> None:
"""Test the RDW vehicle sensors."""
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
state = hass.states.get("sensor.apk_expiration")
entry = entity_registry.async_get("sensor.apk_expiration")
assert entry
assert state
assert entry.unique_id == "11ZKZ3_apk_expiration"
assert state.state == "2022-01-04"
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "APK Expiration"
assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_DATE
assert ATTR_ICON not in state.attributes
assert ATTR_STATE_CLASS not in state.attributes
assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes
state = hass.states.get("sensor.name_registration_date")
entry = entity_registry.async_get("sensor.name_registration_date")
assert entry
assert state
assert entry.unique_id == "11ZKZ3_name_registration_date"
assert state.state == "2021-11-04"
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Name Registration Date"
assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_DATE
assert ATTR_ICON not in state.attributes
assert ATTR_STATE_CLASS not in state.attributes
assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes
assert entry.device_id
device_entry = device_registry.async_get(entry.device_id)
assert device_entry
assert device_entry.identifiers == {(DOMAIN, "11ZKZ3")}
assert device_entry.manufacturer == "Skoda"
assert device_entry.name == "Skoda: 11ZKZ3"
assert device_entry.entry_type == ENTRY_TYPE_SERVICE
assert device_entry.model == "Citigo"
assert (
device_entry.configuration_url
== "https://ovi.rdw.nl/default.aspx?kenteken=11ZKZ3"
)
assert not device_entry.sw_version