From 0e181a824a37b5115d48399bfee8b7844ef7d268 Mon Sep 17 00:00:00 2001 From: Michael Rademaker Date: Sun, 13 Sep 2026 14:24:31 +0200 Subject: [PATCH] Add L2 and L3 voltage and current sensors to EARN-E P1 Meter (#181833) Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> --- .../components/earn_e_p1/coordinator.py | 3 + homeassistant/components/earn_e_p1/sensor.py | 46 ++- .../components/earn_e_p1/strings.json | 8 +- tests/components/earn_e_p1/conftest.py | 10 +- .../earn_e_p1/snapshots/test_sensor.ambr | 264 ++++++++++++++++-- tests/components/earn_e_p1/test_sensor.py | 101 ++++++- 6 files changed, 401 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/earn_e_p1/coordinator.py b/homeassistant/components/earn_e_p1/coordinator.py index c4c61aaca149..8fb48e51d67f 100644 --- a/homeassistant/components/earn_e_p1/coordinator.py +++ b/homeassistant/components/earn_e_p1/coordinator.py @@ -44,6 +44,7 @@ class EarnEP1Coordinator(DataUpdateCoordinator[dict[str, Any]]): self.mac = mac self.model: str | None = None self.sw_version: str | None = None + self.data_complete = False self._listener = listener def _handle_update(self, device: EarnEP1Device, _raw: dict[str, Any]) -> None: @@ -62,6 +63,8 @@ class EarnEP1Coordinator(DataUpdateCoordinator[dict[str, Any]]): model=self.model, sw_version=self.sw_version, ) + # Listeners run synchronously from async_set_updated_data and read this. + self.data_complete = device.data_complete self.async_set_updated_data(device.data) def start(self) -> None: diff --git a/homeassistant/components/earn_e_p1/sensor.py b/homeassistant/components/earn_e_p1/sensor.py index 8003f8f3d373..2d650b5715bc 100644 --- a/homeassistant/components/earn_e_p1/sensor.py +++ b/homeassistant/components/earn_e_p1/sensor.py @@ -46,7 +46,26 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="voltage_l1", - translation_key="voltage_l1", + translation_key="voltage_phase", + translation_placeholders={"phase": "1"}, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + SensorEntityDescription( + key="voltage_l2", + translation_key="voltage_phase", + translation_placeholders={"phase": "2"}, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + SensorEntityDescription( + key="voltage_l3", + translation_key="voltage_phase", + translation_placeholders={"phase": "3"}, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -54,7 +73,26 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="current_l1", - translation_key="current_l1", + translation_key="current_phase", + translation_placeholders={"phase": "1"}, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="current_l2", + translation_key="current_phase", + translation_placeholders={"phase": "2"}, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="current_l3", + translation_key="current_phase", + translation_placeholders={"phase": "3"}, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -140,11 +178,11 @@ async def async_setup_entry( for description in SENSOR_DESCRIPTIONS if description.key in new_keys ) - if not pending_keys: + if not pending_keys or coordinator.data_complete: _async_remove_listener() _async_add_sensors() - if pending_keys: + if pending_keys and not coordinator.data_complete: remove_listener = coordinator.async_add_listener(_async_add_sensors) entry.async_on_unload(_async_remove_listener) diff --git a/homeassistant/components/earn_e_p1/strings.json b/homeassistant/components/earn_e_p1/strings.json index 903cf82b88df..cb95b9fe3cee 100644 --- a/homeassistant/components/earn_e_p1/strings.json +++ b/homeassistant/components/earn_e_p1/strings.json @@ -28,8 +28,8 @@ }, "entity": { "sensor": { - "current_l1": { - "name": "Current L1" + "current_phase": { + "name": "Current phase {phase}" }, "energy_exported_tariff1": { "name": "Energy exported tariff 1" @@ -52,8 +52,8 @@ "power_imported": { "name": "Power imported" }, - "voltage_l1": { - "name": "Voltage L1" + "voltage_phase": { + "name": "Voltage phase {phase}" }, "wifi_rssi": { "name": "Wi-Fi RSSI" diff --git a/tests/components/earn_e_p1/conftest.py b/tests/components/earn_e_p1/conftest.py index 6dcc1cdf6717..d0807c48ebff 100644 --- a/tests/components/earn_e_p1/conftest.py +++ b/tests/components/earn_e_p1/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from earn_e_p1 import EarnEP1Device +from earn_e_p1 import EarnEP1Device, PacketType import pytest from homeassistant.components.earn_e_p1.const import CONF_SERIAL, DOMAIN @@ -25,7 +25,11 @@ MOCK_DEVICE_DATA: dict[str, Any] = { "power_delivered": 2.5, "power_returned": 0.0, "voltage_l1": 230.1, + "voltage_l2": 229.8, + "voltage_l3": 231.5, "current_l1": 10.87, + "current_l2": 8.2, + "current_l3": 4.35, "energy_delivered_tariff1": 12345.678, "energy_delivered_tariff2": 6789.012, "energy_returned_tariff1": 100.0, @@ -44,17 +48,21 @@ DHCP_DISCOVERY = DhcpServiceInfo( def trigger_callback( mock_listener: MagicMock, device_data: dict[str, Any] | None = None, + seen_packet_types: set[PacketType] | None = None, model: str | None = "P1 Meter", sw_version: str | None = "1.0.0", ) -> None: """Trigger the registered listener callback with device data.""" if device_data is None: device_data = MOCK_DEVICE_DATA + if seen_packet_types is None: + seen_packet_types = set(PacketType) callback = mock_listener.register.call_args[0][1] device = EarnEP1Device(host=MOCK_HOST, serial=MOCK_SERIAL) device.model = model device.sw_version = sw_version device.data = device_data + device.seen_packet_types = seen_packet_types callback(device, device_data) diff --git a/tests/components/earn_e_p1/snapshots/test_sensor.ambr b/tests/components/earn_e_p1/snapshots/test_sensor.ambr index b4a881a79c35..3da2f42a116d 100644 --- a/tests/components/earn_e_p1/snapshots/test_sensor.ambr +++ b/tests/components/earn_e_p1/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensor_platform[sensor.earn_e_p1_meter_current_l1-entry] +# name: test_sensor_platform[sensor.earn_e_p1_meter_current_phase_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -15,7 +15,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.earn_e_p1_meter_current_l1', + 'entity_id': 'sensor.earn_e_p1_meter_current_phase_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -23,7 +23,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Current L1', + 'object_id_base': 'Current phase 1', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 2, @@ -31,32 +31,148 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Current L1', + 'original_name': 'Current phase 1', 'platform': 'earn_e_p1', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'current_l1', + 'translation_key': 'current_phase', 'unique_id': 'E0012345678901234_current_l1', 'unit_of_measurement': , }) # --- -# name: test_sensor_platform[sensor.earn_e_p1_meter_current_l1-state] +# name: test_sensor_platform[sensor.earn_e_p1_meter_current_phase_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'current', - : 'EARN-E P1 Meter Current L1', + : 'EARN-E P1 Meter Current phase 1', : , : , }), 'context': , - 'entity_id': 'sensor.earn_e_p1_meter_current_l1', + 'entity_id': 'sensor.earn_e_p1_meter_current_phase_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '10.87', }) # --- +# name: test_sensor_platform[sensor.earn_e_p1_meter_current_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.earn_e_p1_meter_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current phase 2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 2', + 'platform': 'earn_e_p1', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase', + 'unique_id': 'E0012345678901234_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_platform[sensor.earn_e_p1_meter_current_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'EARN-E P1 Meter Current phase 2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.earn_e_p1_meter_current_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.2', + }) +# --- +# name: test_sensor_platform[sensor.earn_e_p1_meter_current_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.earn_e_p1_meter_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current phase 3', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 3', + 'platform': 'earn_e_p1', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase', + 'unique_id': 'E0012345678901234_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_platform[sensor.earn_e_p1_meter_current_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'EARN-E P1 Meter Current phase 3', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.earn_e_p1_meter_current_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.35', + }) +# --- # name: test_sensor_platform[sensor.earn_e_p1_meter_energy_exported_tariff_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -463,7 +579,7 @@ 'state': '2.5', }) # --- -# name: test_sensor_platform[sensor.earn_e_p1_meter_voltage_l1-entry] +# name: test_sensor_platform[sensor.earn_e_p1_meter_voltage_phase_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -479,7 +595,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.earn_e_p1_meter_voltage_l1', + 'entity_id': 'sensor.earn_e_p1_meter_voltage_phase_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -487,7 +603,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Voltage L1', + 'object_id_base': 'Voltage phase 1', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 0, @@ -495,32 +611,148 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Voltage L1', + 'original_name': 'Voltage phase 1', 'platform': 'earn_e_p1', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'voltage_l1', + 'translation_key': 'voltage_phase', 'unique_id': 'E0012345678901234_voltage_l1', 'unit_of_measurement': , }) # --- -# name: test_sensor_platform[sensor.earn_e_p1_meter_voltage_l1-state] +# name: test_sensor_platform[sensor.earn_e_p1_meter_voltage_phase_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'voltage', - : 'EARN-E P1 Meter Voltage L1', + : 'EARN-E P1 Meter Voltage phase 1', : , : , }), 'context': , - 'entity_id': 'sensor.earn_e_p1_meter_voltage_l1', + 'entity_id': 'sensor.earn_e_p1_meter_voltage_phase_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '230.1', }) # --- +# name: test_sensor_platform[sensor.earn_e_p1_meter_voltage_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.earn_e_p1_meter_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage phase 2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2', + 'platform': 'earn_e_p1', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase', + 'unique_id': 'E0012345678901234_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_platform[sensor.earn_e_p1_meter_voltage_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'EARN-E P1 Meter Voltage phase 2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.earn_e_p1_meter_voltage_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '229.8', + }) +# --- +# name: test_sensor_platform[sensor.earn_e_p1_meter_voltage_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.earn_e_p1_meter_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage phase 3', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3', + 'platform': 'earn_e_p1', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase', + 'unique_id': 'E0012345678901234_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_platform[sensor.earn_e_p1_meter_voltage_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'EARN-E P1 Meter Voltage phase 3', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.earn_e_p1_meter_voltage_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231.5', + }) +# --- # name: test_sensor_platform[sensor.earn_e_p1_meter_wi_fi_rssi-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/earn_e_p1/test_sensor.py b/tests/components/earn_e_p1/test_sensor.py index 268b09c821f5..8eb3872f43cc 100644 --- a/tests/components/earn_e_p1/test_sensor.py +++ b/tests/components/earn_e_p1/test_sensor.py @@ -2,23 +2,45 @@ from unittest.mock import MagicMock +from earn_e_p1 import PacketType import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import trigger_callback +from .conftest import MOCK_DEVICE_DATA, trigger_callback from tests.common import MockConfigEntry, snapshot_platform -# A partial packet carries only the instantaneous values, without energy/gas. -PARTIAL_DEVICE_DATA = { +# The meter alternates between a realtime packet holding the instantaneous +# values and a heartbeat packet holding the meter totals. Each type only +# carries the keys the meter itself supports, so a single-phase meter never +# sends the L2/L3 keys and an electricity-only meter never sends gas. +REALTIME_3PHASE = { + "power_delivered": 0.35, + "power_returned": 0.0, + "voltage_l1": 232.0, + "voltage_l2": 231.4, + "voltage_l3": 230.8, + "current_l1": 2.0, + "current_l2": 1.5, + "current_l3": 1.1, +} +REALTIME_1PHASE = { "power_delivered": 0.35, "power_returned": 0.0, "voltage_l1": 232.0, "current_l1": 2.0, } +HEARTBEAT_NO_GAS = { + "energy_delivered_tariff1": 12345.678, + "energy_delivered_tariff2": 6789.012, + "energy_returned_tariff1": 100.0, + "energy_returned_tariff2": 50.0, + "wifiRSSI": -65, +} +HEARTBEAT = {**HEARTBEAT_NO_GAS, "gas_delivered": 1234.567} @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -75,12 +97,16 @@ async def test_sensors_added_when_key_appears_in_later_packet( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - trigger_callback(mock_listener, device_data=PARTIAL_DEVICE_DATA) + trigger_callback( + mock_listener, + device_data=REALTIME_3PHASE, + seen_packet_types={PacketType.REALTIME}, + ) await hass.async_block_till_done() assert len( er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) - ) == len(PARTIAL_DEVICE_DATA) + ) == len(REALTIME_3PHASE) assert hass.states.get("sensor.earn_e_p1_meter_energy_imported_tariff_1") is None assert hass.states.get("sensor.earn_e_p1_meter_gas_consumed") is None @@ -90,7 +116,7 @@ async def test_sensors_added_when_key_appears_in_later_packet( entries = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) - assert len(entries) == 10 + assert len(entries) == len(MOCK_DEVICE_DATA) energy = hass.states.get("sensor.earn_e_p1_meter_energy_imported_tariff_1") assert energy is not None @@ -101,6 +127,69 @@ async def test_sensors_added_when_key_appears_in_later_packet( assert gas.state == "1234.567" +@pytest.mark.parametrize( + ("realtime", "heartbeat", "absent_entity_ids"), + [ + pytest.param( + REALTIME_1PHASE, + HEARTBEAT, + [ + "sensor.earn_e_p1_meter_voltage_phase_2", + "sensor.earn_e_p1_meter_voltage_phase_3", + "sensor.earn_e_p1_meter_current_phase_2", + "sensor.earn_e_p1_meter_current_phase_3", + ], + id="single_phase_meter", + ), + pytest.param( + REALTIME_3PHASE, + HEARTBEAT_NO_GAS, + ["sensor.earn_e_p1_meter_gas_consumed"], + id="no_gas_meter", + ), + ], +) +async def test_unsupported_keys_never_create_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_listener: MagicMock, + entity_registry: er.EntityRegistry, + realtime: dict[str, float], + heartbeat: dict[str, float], + absent_entity_ids: list[str], +) -> None: + """Test keys a meter never sends do not become entities.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + trigger_callback( + mock_listener, + device_data=realtime, + seen_packet_types={PacketType.REALTIME}, + ) + await hass.async_block_till_done() + + trigger_callback(mock_listener, device_data={**realtime, **heartbeat}) + await hass.async_block_till_done() + + for entity_id in absent_entity_ids: + assert hass.states.get(entity_id) is None + + # Counted from the registry rather than the state machine, because the + # Wi-Fi RSSI sensor is disabled by default and so has no state. + assert len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) == len(realtime) + len(heartbeat) + + trigger_callback(mock_listener) + await hass.async_block_till_done() + + # Both packet types have been seen, so the setup listener unsubscribed and + # a packet carrying the unsupported keys can no longer add them. + for entity_id in absent_entity_ids: + assert hass.states.get(entity_id) is None + + async def test_unload_after_all_sensors_added( hass: HomeAssistant, mock_config_entry: MockConfigEntry,