diff --git a/.coveragerc b/.coveragerc index 7594d2d2d98..a4215bc0991 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1534,7 +1534,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 diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py index 75d306b392a..b80163742cb 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -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 diff --git a/homeassistant/components/v2c/config_flow.py b/homeassistant/components/v2c/config_flow.py index 7a08c34834e..0421d882ee6 100644 --- a/homeassistant/components/v2c/config_flow.py +++ b/homeassistant/components/v2c/config_flow.py @@ -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 ) diff --git a/homeassistant/components/v2c/icons.json b/homeassistant/components/v2c/icons.json index 0c0609de347..fa8449135bb 100644 --- a/homeassistant/components/v2c/icons.json +++ b/homeassistant/components/v2c/icons.json @@ -15,6 +15,12 @@ }, "fv_power": { "default": "mdi:solar-power-variant" + }, + "slave_error": { + "default": "mdi:alert" + }, + "battery_power": { + "default": "mdi:home-battery" } }, "switch": { diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index fb234d726e8..e26bf80a514 100644 --- a/homeassistant/components/v2c/manifest.json +++ b/homeassistant/components/v2c/manifest.json @@ -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"] } diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index 871dd65aa75..01b89adea4d 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -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) diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index a60b61831fd..bafbbe36e0c 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -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": { diff --git a/requirements_all.txt b/requirements_all.txt index 6065c83fba6..86e0cf509d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d323973dd0..7591fd0a3c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/v2c/conftest.py b/tests/components/v2c/conftest.py index 3508c0596b2..87c11a3ceef 100644 --- a/tests/components/v2c/conftest.py +++ b/tests/components/v2c/conftest.py @@ -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 diff --git a/tests/components/v2c/snapshots/test_sensor.ambr b/tests/components/v2c/snapshots/test_sensor.ambr index 2504aa2e7c8..0ef9bfe8429 100644 --- a/tests/components/v2c/snapshots/test_sensor.ambr +++ b/tests/components/v2c/snapshots/test_sensor.ambr @@ -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': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_1_1_1_1_photovoltaic_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + }), + 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': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + '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': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + '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': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + }), + ]) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_battery_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + }) +# --- +# 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': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_1_1_1_1_battery_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'sensor.evse_1_1_1_1_slave_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'waiting_wifi', + }) +# --- diff --git a/tests/components/v2c/test_sensor.py b/tests/components/v2c/test_sensor.py index b30dfd436ff..a4a7fe6ca34 100644 --- a/tests/components/v2c/test_sensor.py +++ b/tests/components/v2c/test_sensor.py @@ -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