Add support for V2C Trydan 2.1.7 (#117147)

* Support for firmware 2.1.7

* add device ID as unique_id

* add device ID as unique_id

* add test device id as unique_id

* backward compatibility

* move outside try

* Sensor return type

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* not needed

* make slave error enum state

* fix enum

* Update homeassistant/components/v2c/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/v2c/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/v2c/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* simplify tests

* fix misspellings from upstream library

* add sensor tests

* just enough coverage for enum sensor

* Refactor V2C tests (#117264)

* Refactor V2C tests

* fix rebase issues

* ruff

* review

* fix https://github.com/home-assistant/core/issues/117296

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
pull/118497/head^2
Diogo Gomes 2024-05-30 19:42:48 +01:00 committed by GitHub
parent 43c69c71c2
commit 822273a6a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 586 additions and 7 deletions

View File

@ -1528,7 +1528,6 @@ omit =
homeassistant/components/v2c/coordinator.py
homeassistant/components/v2c/entity.py
homeassistant/components/v2c/number.py
homeassistant/components/v2c/sensor.py
homeassistant/components/v2c/switch.py
homeassistant/components/vallox/__init__.py
homeassistant/components/vallox/coordinator.py

View File

@ -31,6 +31,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
if coordinator.data.ID and entry.unique_id != coordinator.data.ID:
hass.config_entries.async_update_entry(entry, unique_id=coordinator.data.ID)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@ -41,13 +41,18 @@ class V2CConfigFlow(ConfigFlow, domain=DOMAIN):
)
try:
await evse.get_data()
data = await evse.get_data()
except TrydanError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if data.ID:
await self.async_set_unique_id(data.ID)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"EVSE {user_input[CONF_HOST]}", data=user_input
)

View File

@ -15,6 +15,12 @@
},
"fv_power": {
"default": "mdi:solar-power-variant"
},
"slave_error": {
"default": "mdi:alert"
},
"battery_power": {
"default": "mdi:home-battery"
}
},
"switch": {

View File

@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/v2c",
"iot_class": "local_polling",
"requirements": ["pytrydan==0.6.0"]
"requirements": ["pytrydan==0.6.1"]
}

View File

@ -7,6 +7,7 @@ from dataclasses import dataclass
import logging
from pytrydan import TrydanData
from pytrydan.models.trydan import SlaveCommunicationState
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -18,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
from .coordinator import V2CUpdateCoordinator
@ -30,9 +32,11 @@ _LOGGER = logging.getLogger(__name__)
class V2CSensorEntityDescription(SensorEntityDescription):
"""Describes an EVSE Power sensor entity."""
value_fn: Callable[[TrydanData], float]
value_fn: Callable[[TrydanData], StateType]
_SLAVE_ERROR_OPTIONS = [error.name.lower() for error in SlaveCommunicationState]
TRYDAN_SENSORS = (
V2CSensorEntityDescription(
key="charge_power",
@ -75,6 +79,23 @@ TRYDAN_SENSORS = (
device_class=SensorDeviceClass.POWER,
value_fn=lambda evse_data: evse_data.fv_power,
),
V2CSensorEntityDescription(
key="slave_error",
translation_key="slave_error",
value_fn=lambda evse_data: evse_data.slave_error.name.lower(),
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.ENUM,
options=_SLAVE_ERROR_OPTIONS,
),
V2CSensorEntityDescription(
key="battery_power",
translation_key="battery_power",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
value_fn=lambda evse_data: evse_data.battery_power,
entity_registry_enabled_default=False,
),
)
@ -108,6 +129,6 @@ class V2CSensorBaseEntity(V2CBaseEntity, SensorEntity):
self._attr_unique_id = f"{entry_id}_{description.key}"
@property
def native_value(self) -> float | None:
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.data)

View File

@ -1,5 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"step": {
"user": {
"data": {
@ -47,6 +50,49 @@
},
"fv_power": {
"name": "Photovoltaic power"
},
"battery_power": {
"name": "Battery power"
},
"slave_error": {
"name": "Slave error",
"state": {
"no_error": "No error",
"communication": "Communication",
"reading": "Reading",
"slave": "Slave",
"waiting_wifi": "Waiting for Wi-Fi",
"waiting_communication": "Waiting communication",
"wrong_ip": "Wrong IP",
"slave_not_found": "Slave not found",
"wrong_slave": "Wrong slave",
"no_response": "No response",
"clamp_not_connected": "Clamp not connected",
"illegal_function": "Illegal function",
"illegal_data_address": "Illegal data address",
"illegal_data_value": "Illegal data value",
"server_device_failure": "Server device failure",
"acknowledge": "Acknowledge",
"server_device_busy": "Server device busy",
"negative_acknowledge": "Negative acknowledge",
"memory_parity_error": "Memory parity error",
"gateway_path_unavailable": "Gateway path unavailable",
"gateway_target_no_resp": "Gateway target no response",
"server_rtu_inactive244_timeout": "Server RTU inactive/timeout",
"invalid_server": "Invalid server",
"crc_error": "CRC error",
"fc_mismatch": "FC mismatch",
"server_id_mismatch": "Server id mismatch",
"packet_length_error": "Packet length error",
"parameter_count_error": "Parameter count error",
"parameter_limit_error": "Parameter limit error",
"request_queue_full": "Request queue full",
"illegal_ip_or_port": "Illegal IP or port",
"ip_connection_failed": "IP connection failed",
"tcp_head_mismatch": "TCP head mismatch",
"empty_message": "Empty message",
"undefined_error": "Undefined error"
}
}
},
"switch": {

View File

@ -2352,7 +2352,7 @@ pytradfri[async]==9.0.1
pytrafikverket==0.3.10
# homeassistant.components.v2c
pytrydan==0.6.0
pytrydan==0.6.1
# homeassistant.components.usb
pyudev==0.24.1

View File

@ -1831,7 +1831,7 @@ pytradfri[async]==9.0.1
pytrafikverket==0.3.10
# homeassistant.components.v2c
pytrydan==0.6.0
pytrydan==0.6.1
# homeassistant.components.usb
pyudev==0.24.1

View File

@ -48,4 +48,5 @@ def mock_v2c_client() -> Generator[AsyncMock, None, None]:
client = mock_client.return_value
get_data_json = load_json_object_fixture("get_data.json", DOMAIN)
client.get_data.return_value = TrydanData.from_api(get_data_json)
client.firmware_version = get_data_json["FirmwareVersion"]
yield client

View File

@ -1,4 +1,340 @@
# serializer version: 1
# name: test_sensor
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'EVSE 1.1.1.1 Photovoltaic power',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.evse_1_1_1_1_photovoltaic_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
list([
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.evse_1_1_1_1_charge_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': 'mdi:ev-station',
'original_name': 'Charge power',
'platform': 'v2c',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'charge_power',
'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_charge_power',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.evse_1_1_1_1_charge_energy',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Charge energy',
'platform': 'v2c',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'charge_energy',
'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_charge_energy',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.evse_1_1_1_1_charge_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Charge time',
'platform': 'v2c',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'charge_time',
'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_charge_time',
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
}),
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.evse_1_1_1_1_house_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'House power',
'platform': 'v2c',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'house_power',
'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_house_power',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.evse_1_1_1_1_photovoltaic_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'Photovoltaic power',
'platform': 'v2c',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'fv_power',
'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_fv_power',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'no_error',
'communication',
'reading',
'slave',
'waiting_wifi',
'waiting_communication',
'wrong_ip',
'slave_not_found',
'wrong_slave',
'no_response',
'clamp_not_connected',
'illegal_function',
'illegal_data_address',
'illegal_data_value',
'server_device_failure',
'acknowledge',
'server_device_busy',
'negative_acknowledge',
'memory_parity_error',
'gateway_path_unavailable',
'gateway_target_no_resp',
'server_rtu_inactive244_timeout',
'invalid_server',
'crc_error',
'fc_missmatch',
'server_id_missmatch',
'packet_length_error',
'parameter_count_error',
'parameter_limit_error',
'request_queue_full',
'illegal_ip_or_port',
'ip_connection_failed',
'tcp_head_missmatch',
'empty_message',
'undefined_error',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': <RegistryEntryDisabler.INTEGRATION: 'integration'>,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.evse_1_1_1_1_slave_error',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Slave error',
'platform': 'v2c',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'slave_error',
'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_slave_error',
'unit_of_measurement': None,
}),
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': <RegistryEntryDisabler.INTEGRATION: 'integration'>,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.evse_1_1_1_1_battery_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'Battery power',
'platform': 'v2c',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'battery_power',
'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_battery_power',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
])
# ---
# name: test_sensor[sensor.evse_1_1_1_1_battery_power-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.evse_1_1_1_1_battery_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'Battery power',
'platform': 'v2c',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'battery_power',
'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_battery_power',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_sensor[sensor.evse_1_1_1_1_battery_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'EVSE 1.1.1.1 Battery power',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.evse_1_1_1_1_battery_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---
# name: test_sensor[sensor.evse_1_1_1_1_charge_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@ -255,3 +591,125 @@
'state': '0.0',
})
# ---
# name: test_sensor[sensor.evse_1_1_1_1_slave_error-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'no_error',
'communication',
'reading',
'slave',
'waiting_wifi',
'waiting_communication',
'wrong_ip',
'slave_not_found',
'wrong_slave',
'no_response',
'clamp_not_connected',
'illegal_function',
'illegal_data_address',
'illegal_data_value',
'server_device_failure',
'acknowledge',
'server_device_busy',
'negative_acknowledge',
'memory_parity_error',
'gateway_path_unavailable',
'gateway_target_no_resp',
'server_rtu_inactive244_timeout',
'invalid_server',
'crc_error',
'fc_mismatch',
'server_id_mismatch',
'packet_length_error',
'parameter_count_error',
'parameter_limit_error',
'request_queue_full',
'illegal_ip_or_port',
'ip_connection_failed',
'tcp_head_mismatch',
'empty_message',
'undefined_error',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.evse_1_1_1_1_slave_error',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Slave error',
'platform': 'v2c',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'slave_error',
'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_slave_error',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[sensor.evse_1_1_1_1_slave_error-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'EVSE 1.1.1.1 Slave error',
'options': list([
'no_error',
'communication',
'reading',
'slave',
'waiting_wifi',
'waiting_communication',
'wrong_ip',
'slave_not_found',
'wrong_slave',
'no_response',
'clamp_not_connected',
'illegal_function',
'illegal_data_address',
'illegal_data_value',
'server_device_failure',
'acknowledge',
'server_device_busy',
'negative_acknowledge',
'memory_parity_error',
'gateway_path_unavailable',
'gateway_target_no_resp',
'server_rtu_inactive244_timeout',
'invalid_server',
'crc_error',
'fc_mismatch',
'server_id_mismatch',
'packet_length_error',
'parameter_count_error',
'parameter_limit_error',
'request_queue_full',
'illegal_ip_or_port',
'ip_connection_failed',
'tcp_head_mismatch',
'empty_message',
'undefined_error',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.evse_1_1_1_1_slave_error',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'waiting_wifi',
})
# ---

View File

@ -25,3 +25,43 @@ async def test_sensor(
with patch("homeassistant.components.v2c.PLATFORMS", [Platform.SENSOR]):
await init_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
from homeassistant.components.v2c.sensor import _SLAVE_ERROR_OPTIONS
assert [
"no_error",
"communication",
"reading",
"slave",
"waiting_wifi",
"waiting_communication",
"wrong_ip",
"slave_not_found",
"wrong_slave",
"no_response",
"clamp_not_connected",
"illegal_function",
"illegal_data_address",
"illegal_data_value",
"server_device_failure",
"acknowledge",
"server_device_busy",
"negative_acknowledge",
"memory_parity_error",
"gateway_path_unavailable",
"gateway_target_no_resp",
"server_rtu_inactive244_timeout",
"invalid_server",
"crc_error",
"fc_mismatch",
"server_id_mismatch",
"packet_length_error",
"parameter_count_error",
"parameter_limit_error",
"request_queue_full",
"illegal_ip_or_port",
"ip_connection_failed",
"tcp_head_mismatch",
"empty_message",
"undefined_error",
] == _SLAVE_ERROR_OPTIONS