Pick up SolarEdge Modbus meters and batteries wired in or out later (#180830)

This commit is contained in:
Franck Nijhof
2026-08-30 16:24:55 -04:00
committed by GitHub
parent 345ebf6925
commit 4ae28fc35c
5 changed files with 296 additions and 3 deletions
+205 -1
View File
@@ -7,11 +7,12 @@ from freezegun.api import FrozenDateTimeFactory
from modbus_connection import (
IllegalDataAddressError,
ModbusTimeoutError,
ModbusUnit,
ServerDeviceFailureError,
)
from modbus_connection.mock import MockModbusConnection, MockModbusUnit
import pytest
from solaredged import SolarEdgeConnectionError
from solaredged import SolarEdge, SolarEdgeConnectionError
from homeassistant.components.select import (
ATTR_OPTION,
@@ -19,6 +20,7 @@ from homeassistant.components.select import (
SERVICE_SELECT_OPTION,
)
from homeassistant.components.solaredge_modbus.const import (
ATTACHMENT_SCAN_INTERVAL,
DOMAIN,
SCAN_INTERVAL,
SETTINGS_SCAN_INTERVAL,
@@ -58,6 +60,9 @@ METER_MODEL_REGISTER = 40188
# An address inside the pooled storage and export control read.
SITE_CONTROL_REGISTER = 57348
# Where the first meter's serial number lives.
METER_SERIAL_REGISTER = 40171
EXPORT_LIMITATION_ENTITY = "select.solaredge_se10000h_export_limitation"
EXTERNAL_PRODUCTION_ENTITY = "switch.solaredge_se10000h_external_production"
@@ -620,6 +625,205 @@ async def test_concurrent_control_writes_keep_both_changes(
assert state.state == STATE_ON
async def _tick_attachment_check(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> int:
"""Let the check for changed hardware run, and report what it cost.
Setting up probes the device, so a check that reloads the entry probes
twice: once to look, once to build the entry again.
"""
probes = 0
probe = SolarEdge.async_probe
async def counting_probe(unit: ModbusUnit) -> SolarEdge:
nonlocal probes
probes += 1
return await probe(unit)
with patch.object(SolarEdge, "async_probe", counting_probe):
freezer.tick(ATTACHMENT_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
return probes
async def test_meter_added_later_is_picked_up(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A meter wired to a running installation appears without being asked.
What is attached is read while the entry is set up, so the entry loads
again once a probe finds something that was not there before.
"""
mock_modbus_unit.fail_read(METER_MODEL_REGISTER, IllegalDataAddressError())
await _setup(hass, mock_config_entry)
meter = (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}")
assert (
device_registry.async_get_device_by_identifier(
meter, mock_config_entry.entry_id
)
is None
)
# The meter is wired in and answers from now on.
mock_modbus_unit.fail_read(METER_MODEL_REGISTER, None)
await _tick_attachment_check(hass, freezer)
assert (
device_registry.async_get_device_by_identifier(
meter, mock_config_entry.entry_id
)
is not None
)
async def test_battery_removed_later_is_dropped(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A battery taken out of a running installation stops being a device."""
await _setup(hass, mock_config_entry)
battery = (DOMAIN, f"{SERIAL_NUMBER}_battery_{BATTERY_SERIAL_NUMBERS[0]}")
assert (
device_registry.async_get_device_by_identifier(
battery, mock_config_entry.entry_id
)
is not None
)
mock_modbus_unit.fail_read(BATTERY_RATED_ENERGY, IllegalDataAddressError())
await _tick_attachment_check(hass, freezer)
assert (
device_registry.async_get_device_by_identifier(
battery, mock_config_entry.entry_id
)
is None
)
async def test_replaced_meter_is_picked_up(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""Another meter in the same place is another device, while running too.
Swapping one meter for another leaves the count alone, so what gives it
away is the serial number the polls have been reading all along.
"""
await _setup(hass, mock_config_entry)
replacement = "7E9C55A6"
padded = replacement.ljust(32, "\0").encode()
mock_modbus_unit.holding.update(
{
METER_SERIAL_REGISTER + index: (padded[index * 2] << 8)
| padded[index * 2 + 1]
for index in range(16)
}
)
# The swap is seen without probing, so the only probe here is the reload's.
assert await _tick_attachment_check(hass, freezer) == 1
assert (
device_registry.async_get_device_by_identifier(
(DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}"),
mock_config_entry.entry_id,
)
is None
)
assert (
device_registry.async_get_device_by_identifier(
(DOMAIN, f"{SERIAL_NUMBER}_meter_{replacement}"),
mock_config_entry.entry_id,
)
is not None
)
async def test_silent_attachment_does_not_trigger_a_reload(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A meter that did not answer the probe is not a meter that was removed.
Silence is taken for absence while probing, so reloading on it would drop a
device, and its history, over a single timeout.
"""
await _setup(hass, mock_config_entry)
meter = (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}")
mock_modbus_unit.fail_read(METER_MODEL_REGISTER, ModbusTimeoutError("timed out"))
assert await _tick_attachment_check(hass, freezer) == 1
assert (
device_registry.async_get_device_by_identifier(
meter, mock_config_entry.entry_id
)
is not None
)
async def test_unchanged_attachments_leave_the_entry_alone(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
) -> None:
"""Nothing changed means nothing happens, however often it is checked."""
await _setup(hass, mock_config_entry)
coordinator = mock_config_entry.runtime_data.readings
assert await _tick_attachment_check(hass, freezer) == 1
assert mock_config_entry.state is ConfigEntryState.LOADED
# A reload would have built new coordinators.
assert mock_config_entry.runtime_data.readings is coordinator
async def test_a_dead_probe_leaves_the_entry_where_it_is(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""An inverter that stops answering says nothing about what is wired to it.
The coordinators already report an inverter gone quiet; reloading on top of
that would only take the entry down with it.
"""
await _setup(hass, mock_config_entry)
coordinator = mock_config_entry.runtime_data.readings
mock_modbus_unit.fail_requests(ModbusTimeoutError("link died"))
assert await _tick_attachment_check(hass, freezer) == 1
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_config_entry.runtime_data.readings is coordinator
async def test_setup_retry_when_device_unresponsive(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,