From b8fe74f56d97a55364bd619d1d87334dc35a157f Mon Sep 17 00:00:00 2001 From: darkrain-nl Date: Fri, 18 Sep 2026 21:02:18 +0200 Subject: [PATCH] Read the Sofar meter counters from meter_energy (#181729) --- homeassistant/components/sofar/sensor.py | 12 +-- tests/components/sofar/__init__.py | 13 +++ tests/components/sofar/test_init.py | 104 +++++++++++++++++++++++ tests/components/sofar/test_sensor.py | 20 +++-- 4 files changed, 134 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sofar/sensor.py b/homeassistant/components/sofar/sensor.py index 6e6bcedfd6de..70cece2e980d 100644 --- a/homeassistant/components/sofar/sensor.py +++ b/homeassistant/components/sofar/sensor.py @@ -1153,7 +1153,7 @@ SENSOR_DESCRIPTIONS: tuple[SofarSensorDescription, ...] = ( ), SofarSensorDescription( key="load_consumption_today", - component="energy", + component="meter_energy", translation_key="load_consumption_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -1163,7 +1163,7 @@ SENSOR_DESCRIPTIONS: tuple[SofarSensorDescription, ...] = ( ), SofarSensorDescription( key="load_consumption_total", - component="energy", + component="meter_energy", translation_key="load_consumption_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -1172,7 +1172,7 @@ SENSOR_DESCRIPTIONS: tuple[SofarSensorDescription, ...] = ( ), SofarSensorDescription( key="import_energy_today", - component="energy", + component="meter_energy", translation_key="import_energy_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -1182,7 +1182,7 @@ SENSOR_DESCRIPTIONS: tuple[SofarSensorDescription, ...] = ( ), SofarSensorDescription( key="import_energy_total", - component="energy", + component="meter_energy", translation_key="import_energy_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -1191,7 +1191,7 @@ SENSOR_DESCRIPTIONS: tuple[SofarSensorDescription, ...] = ( ), SofarSensorDescription( key="export_energy_today", - component="energy", + component="meter_energy", translation_key="export_energy_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -1201,7 +1201,7 @@ SENSOR_DESCRIPTIONS: tuple[SofarSensorDescription, ...] = ( ), SofarSensorDescription( key="export_energy_total", - component="energy", + component="meter_energy", translation_key="export_energy_total", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, diff --git a/tests/components/sofar/__init__.py b/tests/components/sofar/__init__.py index f9eee4687044..6f2441cdc6dd 100644 --- a/tests/components/sofar/__init__.py +++ b/tests/components/sofar/__init__.py @@ -50,6 +50,19 @@ def seed_pv_inverter(unit: MockModbusUnit, serial: str = MOCK_SERIAL) -> None: unit.holding[0x0685] = 1000 # solar_generation_today low word -> 10.0 kWh +def deny_meter_energy(unit: MockModbusUnit) -> None: + """Seed a model that denies the meter block from 0x0688 on.""" + # Bits 0-3 must stay set, or the mask is ignored and denies nothing. + unit.holding[0x0683] = 0x00FF + + +def serve_meter_energy(unit: MockModbusUnit) -> None: + """Seed a model that serves the whole meter block.""" + unit.holding[0x0683] = 0xFFFF # 0x0680-0x068F + unit.holding[0x0682] = 0x000F # 0x0690-0x0693 + unit.holding[0x068B] = 10000 # load_consumption_total -> 1000.0 kWh + + def seed_hybrid_inverter( unit: MockModbusUnit, serial: str = MOCK_HYBRID_SERIAL ) -> None: diff --git a/tests/components/sofar/test_init.py b/tests/components/sofar/test_init.py index be162081ff18..1e08aed2e165 100644 --- a/tests/components/sofar/test_init.py +++ b/tests/components/sofar/test_init.py @@ -29,7 +29,9 @@ from . import ( MOCK_SERIAL, MOCK_SW_VERSION, MOCK_USER_INPUT, + deny_meter_energy, seed_hybrid_inverter, + serve_meter_energy, ) from tests.common import ( @@ -178,6 +180,108 @@ async def test_setup_skips_seeding_an_unusable_restored_total( mock_seed.assert_not_called() +METER_ENERGY_KEYS = ( + "load_consumption_today", + "load_consumption_total", + "import_energy_today", + "import_energy_total", + "export_energy_today", + "export_energy_total", +) + + +async def test_setup_creates_no_meter_sensors_a_model_denies( + hass: HomeAssistant, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test an unmetered model publishes none of the meter sensors.""" + mock_config_entry.add_to_hass(hass) + deny_meter_energy(mock_connection.for_unit(1)) + + with patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert not any( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_{key}" + ) + for key in METER_ENERGY_KEYS + ) + # Solar generation shares the block but its own addresses stay valid. + assert ( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_solar_generation_total" + ) + is not None + ) + + +async def test_setup_keeps_meter_sensors_a_model_serves( + hass: HomeAssistant, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test a metered model keeps all six sensors and their real values.""" + mock_config_entry.add_to_hass(hass) + serve_meter_energy(mock_connection.for_unit(1)) + + with patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert all( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_{key}" + ) + for key in METER_ENERGY_KEYS + ) + entity_id = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_load_consumption_total" + ) + assert hass.states.get(entity_id).state == "1000.0" + + +async def test_setup_keeps_meter_sensors_when_no_mask_is_published( + hass: HomeAssistant, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test a model publishing no usable mask keeps polling the meter block.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert all( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_{key}" + ) + is not None + for key in METER_ENERGY_KEYS + ) + + async def test_setup_entry_unrecognized_inverter_raises_setup_error( hass: HomeAssistant, ) -> None: diff --git a/tests/components/sofar/test_sensor.py b/tests/components/sofar/test_sensor.py index 413421cfabce..0da9e4a64ac1 100644 --- a/tests/components/sofar/test_sensor.py +++ b/tests/components/sofar/test_sensor.py @@ -261,11 +261,11 @@ async def test_total_sensor_restore_data_parsing( device = runtime_data.readings.device description = SofarSensorDescription( key="load_consumption_total", - component="energy", + component="meter_energy", translation_key="load_consumption_total", ) - device.energy.load_consumption_total = None + device.meter_energy.load_consumption_total = None sensor = SofarTotalSensor(runtime_data, description) sensor.hass = hass sensor.async_get_last_sensor_data = AsyncMock( @@ -290,11 +290,11 @@ async def test_total_sensor_restore_data_parsing( await blank_sensor.async_added_to_hass() assert blank_sensor.native_value is None - device.energy.load_consumption_total = 120.0 + device.meter_energy.load_consumption_total = 120.0 total_sensor = SofarTotalSensor(runtime_data, description) assert total_sensor.native_value == 120.0 - device.energy.load_consumption_total = None + device.meter_energy.load_consumption_total = None unset_sensor = SofarTotalSensor(runtime_data, description) assert unset_sensor.native_value is None @@ -307,7 +307,7 @@ async def test_total_sensor_seeds_high_water_from_restored_value( device = runtime_data.readings.device description = SofarSensorDescription( key="load_consumption_total", - component="energy", + component="meter_energy", translation_key="load_consumption_total", state_class=SensorStateClass.TOTAL_INCREASING, ) @@ -316,7 +316,7 @@ async def test_total_sensor_seeds_high_water_from_restored_value( sensor.async_get_last_sensor_data = AsyncMock( return_value=SimpleNamespace(native_value="555.5") ) - with patch.object(device.energy, "seed_high_water") as mock_seed: + with patch.object(device.meter_energy, "seed_high_water") as mock_seed: await sensor.async_added_to_hass() mock_seed.assert_called_once_with("load_consumption_total", 555.5) @@ -342,7 +342,7 @@ async def test_total_sensor_dead_link_unavailable( runtime_data = init_integration.runtime_data description = SofarSensorDescription( key="load_consumption_total", - component="energy", + component="meter_energy", translation_key="load_consumption_total", state_class=SensorStateClass.TOTAL_INCREASING, ) @@ -390,13 +390,15 @@ async def test_total_sensor_total_increasing_uses_corrected_value( runtime_data = init_integration.runtime_data description = SofarSensorDescription( key="load_consumption_total", - component="energy", + component="meter_energy", translation_key="load_consumption_total", state_class=SensorStateClass.TOTAL_INCREASING, ) device = runtime_data.readings.device sensor = SofarTotalSensor(runtime_data, description) - with patch.object(device.energy, "corrected", return_value=42.0) as mock_corrected: + with patch.object( + device.meter_energy, "corrected", return_value=42.0 + ) as mock_corrected: assert sensor.native_value == 42.0 mock_corrected.assert_called_once_with("load_consumption_total") assert sensor.available