Add V2C Trydan EVSE integration (#103478)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
pull/103614/head
Diogo Gomes 2023-11-07 20:53:22 +00:00 committed by GitHub
parent 9c2febc72e
commit 0d67557106
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 427 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
"""Constants for the V2C integration."""
DOMAIN = "v2c"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -513,6 +513,7 @@ FLOWS = {
"upnp",
"uptime",
"uptimerobot",
"v2c",
"vallox",
"velbus",
"venstar",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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