Read the Sofar meter counters from meter_energy (#181729)

This commit is contained in:
darkrain-nl
2026-09-18 21:02:18 +02:00
committed by GitHub
parent b5a6c533bb
commit b8fe74f56d
4 changed files with 134 additions and 15 deletions
+6 -6
View File
@@ -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,
+13
View File
@@ -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:
+104
View File
@@ -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:
+11 -9
View File
@@ -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