From 0d67557106f3608c40ce341cb3f4baa229b7cfc0 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 7 Nov 2023 20:53:22 +0000 Subject: [PATCH] Add V2C Trydan EVSE integration (#103478) Co-authored-by: Joost Lekkerkerker --- .coveragerc | 4 + CODEOWNERS | 2 + homeassistant/components/v2c/__init__.py | 38 +++++++++ homeassistant/components/v2c/config_flow.py | 57 +++++++++++++ homeassistant/components/v2c/const.py | 3 + homeassistant/components/v2c/coordinator.py | 41 +++++++++ homeassistant/components/v2c/entity.py | 41 +++++++++ homeassistant/components/v2c/manifest.json | 9 ++ homeassistant/components/v2c/sensor.py | 93 +++++++++++++++++++++ homeassistant/components/v2c/strings.json | 25 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/v2c/__init__.py | 1 + tests/components/v2c/conftest.py | 14 ++++ tests/components/v2c/test_config_flow.py | 86 +++++++++++++++++++ 17 files changed, 427 insertions(+) create mode 100644 homeassistant/components/v2c/__init__.py create mode 100644 homeassistant/components/v2c/config_flow.py create mode 100644 homeassistant/components/v2c/const.py create mode 100644 homeassistant/components/v2c/coordinator.py create mode 100644 homeassistant/components/v2c/entity.py create mode 100644 homeassistant/components/v2c/manifest.json create mode 100644 homeassistant/components/v2c/sensor.py create mode 100644 homeassistant/components/v2c/strings.json create mode 100644 tests/components/v2c/__init__.py create mode 100644 tests/components/v2c/conftest.py create mode 100644 tests/components/v2c/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 99685df79d7..0bd6d40ac34 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1430,6 +1430,10 @@ omit = homeassistant/components/upnp/device.py homeassistant/components/upnp/sensor.py homeassistant/components/vasttrafik/sensor.py + homeassistant/components/v2c/__init__.py + homeassistant/components/v2c/coordinator.py + homeassistant/components/v2c/entity.py + homeassistant/components/v2c/sensor.py homeassistant/components/velbus/__init__.py homeassistant/components/velbus/binary_sensor.py homeassistant/components/velbus/button.py diff --git a/CODEOWNERS b/CODEOWNERS index 0a594d71b77..0381cb9aec6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1373,6 +1373,8 @@ build.json @home-assistant/supervisor /tests/components/usgs_earthquakes_feed/ @exxamalte /homeassistant/components/utility_meter/ @dgomes /tests/components/utility_meter/ @dgomes +/homeassistant/components/v2c/ @dgomes +/tests/components/v2c/ @dgomes /homeassistant/components/vacuum/ @home-assistant/core /tests/components/vacuum/ @home-assistant/core /homeassistant/components/vallox/ @andre-richter @slovdahl @viiru- diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py new file mode 100644 index 00000000000..030ae56bb79 --- /dev/null +++ b/homeassistant/components/v2c/__init__.py @@ -0,0 +1,38 @@ +"""The V2C integration.""" +from __future__ import annotations + +from pytrydan import Trydan + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import get_async_client + +from .const import DOMAIN +from .coordinator import V2CUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up V2C from a config entry.""" + + host = entry.data[CONF_HOST] + trydan = Trydan(host, get_async_client(hass, verify_ssl=False)) + coordinator = V2CUpdateCoordinator(hass, trydan, host) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/v2c/config_flow.py b/homeassistant/components/v2c/config_flow.py new file mode 100644 index 00000000000..382b41d3994 --- /dev/null +++ b/homeassistant/components/v2c/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for V2C integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pytrydan import Trydan +from pytrydan.exceptions import TrydanError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.httpx_client import get_async_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for V2C.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + evse = Trydan( + user_input[CONF_HOST], + client=get_async_client(self.hass, verify_ssl=False), + ) + + try: + await evse.get_data() + except TrydanError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=f"EVSE {user_input[CONF_HOST]}", data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/v2c/const.py b/homeassistant/components/v2c/const.py new file mode 100644 index 00000000000..b568368f718 --- /dev/null +++ b/homeassistant/components/v2c/const.py @@ -0,0 +1,3 @@ +"""Constants for the V2C integration.""" + +DOMAIN = "v2c" diff --git a/homeassistant/components/v2c/coordinator.py b/homeassistant/components/v2c/coordinator.py new file mode 100644 index 00000000000..b2db66f1b80 --- /dev/null +++ b/homeassistant/components/v2c/coordinator.py @@ -0,0 +1,41 @@ +"""The v2c component.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from pytrydan import Trydan, TrydanData +from pytrydan.exceptions import TrydanError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +class V2CUpdateCoordinator(DataUpdateCoordinator[TrydanData]): + """DataUpdateCoordinator to gather data from any v2c.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, evse: Trydan, host: str) -> None: + """Initialize DataUpdateCoordinator for a v2c evse.""" + self.evse = evse + super().__init__( + hass, + _LOGGER, + name=f"EVSE {host}", + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> TrydanData: + """Fetch sensor data from api.""" + try: + data: TrydanData = await self.evse.get_data() + _LOGGER.debug("Received data: %s", data) + return data + except TrydanError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/v2c/entity.py b/homeassistant/components/v2c/entity.py new file mode 100644 index 00000000000..c00e221d397 --- /dev/null +++ b/homeassistant/components/v2c/entity.py @@ -0,0 +1,41 @@ +"""Support for V2C EVSE.""" +from __future__ import annotations + +from pytrydan import TrydanData + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import V2CUpdateCoordinator + + +class V2CBaseEntity(CoordinatorEntity[V2CUpdateCoordinator]): + """Defines a base v2c entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Init the V2C base entity.""" + self.entity_description = description + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.evse.host)}, + manufacturer="V2C", + model="Trydan", + name=coordinator.name, + sw_version=coordinator.evse.firmware_version, + ) + + @property + def data(self) -> TrydanData: + """Return v2c evse data.""" + data = self.coordinator.data + assert data is not None + return data diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json new file mode 100644 index 00000000000..ce81f3e1424 --- /dev/null +++ b/homeassistant/components/v2c/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "v2c", + "name": "V2C", + "codeowners": ["@dgomes"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/v2c", + "iot_class": "local_polling", + "requirements": ["pytrydan==0.1.2"] +} diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py new file mode 100644 index 00000000000..60ef582ce8d --- /dev/null +++ b/homeassistant/components/v2c/sensor.py @@ -0,0 +1,93 @@ +"""Support for V2C EVSE sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pytrydan import TrydanData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import V2CUpdateCoordinator +from .entity import V2CBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class V2CPowerRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[TrydanData], float] + + +@dataclass +class V2CPowerSensorEntityDescription( + SensorEntityDescription, V2CPowerRequiredKeysMixin +): + """Describes an EVSE Power sensor entity.""" + + +POWER_SENSORS = ( + V2CPowerSensorEntityDescription( + key="charge_power", + translation_key="charge_power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + value_fn=lambda evse_data: evse_data.charge_power, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up V2C sensor platform.""" + coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[Entity] = [ + V2CPowerSensorEntity(coordinator, description, config_entry.entry_id) + for description in POWER_SENSORS + ] + async_add_entities(entities) + + +class V2CSensorBaseEntity(V2CBaseEntity, SensorEntity): + """Defines a base v2c sensor entity.""" + + +class V2CPowerSensorEntity(V2CSensorBaseEntity): + """V2C Power sensor entity.""" + + entity_description: V2CPowerSensorEntityDescription + _attr_icon = "mdi:ev-station" + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: SensorEntityDescription, + entry_id: str, + ) -> None: + """Initialize V2C Power entity.""" + super().__init__(coordinator, description) + self._attr_unique_id = f"{entry_id}_{description.key}" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.data) diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json new file mode 100644 index 00000000000..3a87f91ebc5 --- /dev/null +++ b/homeassistant/components/v2c/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "charge_power": { + "name": "Charge power" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4cd6a93d83a..b0327dbdc29 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -513,6 +513,7 @@ FLOWS = { "upnp", "uptime", "uptimerobot", + "v2c", "vallox", "velbus", "venstar", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 475a1c47eb2..e6ceda10924 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6149,6 +6149,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "v2c": { + "name": "V2C", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "vallox": { "name": "Vallox", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index e966e0871f7..9c5b2e5d28c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2225,6 +2225,9 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_weatherstation pytrafikverket==0.3.8 +# homeassistant.components.v2c +pytrydan==0.1.2 + # homeassistant.components.usb pyudev==0.23.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d469ed5038d..b251418a0cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1660,6 +1660,9 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_weatherstation pytrafikverket==0.3.8 +# homeassistant.components.v2c +pytrydan==0.1.2 + # homeassistant.components.usb pyudev==0.23.2 diff --git a/tests/components/v2c/__init__.py b/tests/components/v2c/__init__.py new file mode 100644 index 00000000000..fdb29e58644 --- /dev/null +++ b/tests/components/v2c/__init__.py @@ -0,0 +1 @@ +"""Tests for the V2C integration.""" diff --git a/tests/components/v2c/conftest.py b/tests/components/v2c/conftest.py new file mode 100644 index 00000000000..85831b607b7 --- /dev/null +++ b/tests/components/v2c/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the V2C tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.v2c.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/v2c/test_config_flow.py b/tests/components/v2c/test_config_flow.py new file mode 100644 index 00000000000..0124c1abb9c --- /dev/null +++ b/tests/components/v2c/test_config_flow.py @@ -0,0 +1,86 @@ +"""Test the V2C config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest +from pytrydan.exceptions import TrydanError + +from homeassistant import config_entries +from homeassistant.components.v2c.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "pytrydan.Trydan.get_data", + return_value={}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "EVSE 1.1.1.1" + assert result2["data"] == { + "host": "1.1.1.1", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TrydanError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_cannot_connect( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pytrydan.Trydan.get_data", + side_effect=side_effect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": error} + + with patch( + "pytrydan.Trydan.get_data", + return_value={}, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "EVSE 1.1.1.1" + assert result3["data"] == { + "host": "1.1.1.1", + }