Put the control blocks in the SolarEdge Modbus diagnostics (#183508)

This commit is contained in:
Franck Nijhof
2026-09-29 07:25:36 +02:00
committed by GitHub
parent 3f74b48d8b
commit bfad30df0a
3 changed files with 138 additions and 1 deletions
@@ -3,6 +3,7 @@
from typing import Any
from modbus_connection.model import Component, RegisterField
from solaredged import SolarEdge
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
@@ -31,6 +32,29 @@ def _component_data(component: Component) -> dict[str, Any]:
return {name: getattr(component, name) for name in sorted(names)}
def _optional_component_data(component: Component | None) -> dict[str, Any] | None:
"""A component's data, or None for a block this device does not have."""
if component is None:
return None
return _component_data(component)
def _sunspec_models(solaredge: SolarEdge) -> list[dict[str, int]] | None:
"""The device's SunSpec model chain, in the order it serves them.
What the inverter says it carries and where, which is the only account of
it that survives a block not being found: everything else here describes
what was read, not what was looked for.
"""
if solaredge.sunspec_models is None:
return None
return [
{"model_id": model.model_id, "address": model.address, "length": model.length}
for model in solaredge.sunspec_models.chain
]
def _poll_data(coordinator: SolarEdgeModbusDataUpdateCoordinator) -> dict[str, Any]:
"""What a coordinator's most recent poll got out of the device."""
return {
@@ -54,7 +78,10 @@ async def async_get_config_entry_diagnostics(
solaredge = runtime_data.solaredge
data: dict[str, Any] = {
"polls": {"readings": _poll_data(runtime_data.readings)},
"polls": {
"readings": _poll_data(runtime_data.readings),
"settings": _poll_data(runtime_data.settings),
},
"common": _component_data(solaredge.common),
"inverter": _component_data(solaredge.inverter),
"mmppt": (
@@ -64,6 +91,13 @@ async def async_get_config_entry_diagnostics(
),
"meters": [_component_data(meter) for meter in solaredge.meters],
"batteries": [_component_data(battery) for battery in solaredge.batteries],
"storage_control": _optional_component_data(solaredge.storage_control),
"export_control": _optional_component_data(solaredge.export_control),
"power_control": _optional_component_data(solaredge.power_control),
"advanced_power_control": _optional_component_data(
solaredge.advanced_power_control
),
"sunspec_models": _sunspec_models(solaredge),
"unresponsive_blocks": sorted(solaredge.unresponsive_blocks),
}
@@ -1,6 +1,10 @@
# serializer version: 1
# name: test_diagnostics
dict({
'advanced_power_control': dict({
'enabled': False,
'reactive_power_config': 0,
}),
'batteries': list([
dict({
'dc_current': -0.0,
@@ -57,6 +61,15 @@
'serial_number': '**REDACTED**',
'version': '',
}),
'export_control': dict({
'external_production': False,
'external_production_max': 0.0,
'limit_type': 0,
'mode': None,
'negative_site_limit': False,
'site_limit': 0.0,
'site_limit_raw': 0.0,
}),
'inverter': dict({
'ac_current': 39.99,
'ac_current_a': 0.0,
@@ -154,7 +167,33 @@
'meters[0]',
]),
}),
'settings': dict({
'failed': dict({
}),
'updated': list([
'advanced_power_control',
'power_control',
'site_control',
]),
}),
}),
'power_control': dict({
'active_power_limit': 0,
'cos_phi': 0.0,
'rrcr_state': 0,
}),
'storage_control': dict({
'ac_charge_limit': 0.0,
'ac_charge_policy': 0,
'backup_reserve': 2.0,
'charge_limit': 11400.0,
'command_mode': None,
'command_timeout': 3600,
'control_mode': 1,
'default_mode': 0,
'discharge_limit': 11400.0,
}),
'sunspec_models': None,
'unresponsive_blocks': list([
]),
})
@@ -1,13 +1,25 @@
"""Tests for the SolarEdge Modbus diagnostics."""
from modbus_connection import IllegalDataAddressError
from modbus_connection.mock import MockModbusUnit
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from .conftest import add_storage_capacity
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
# The control blocks, each absent on a device that refuses its base address.
CONTROL_BASES = {
"storage_control": 57348,
"export_control": 57344,
"power_control": 61440,
"advanced_power_control": 61696,
}
async def test_diagnostics(
hass: HomeAssistant,
@@ -24,3 +36,55 @@ async def test_diagnostics(
await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry)
== snapshot
)
async def test_diagnostics_without_control_blocks(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A block this device does not have is named in the dump, as null."""
for address in CONTROL_BASES.values():
mock_modbus_unit.fail_read(address, IllegalDataAddressError())
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
diagnostics = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)
for name in CONTROL_BASES:
assert diagnostics[name] is None
async def test_diagnostics_reports_the_sunspec_chain(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A device that serves a model chain has it in the dump.
It is what explains a block that was looked for and not found, so it is
worth having even where nothing was made of the models it names.
"""
add_storage_capacity(mock_modbus_unit, state_of_charge=5960)
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
diagnostics = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)
assert [model["model_id"] for model in diagnostics["sunspec_models"]] == [
1,
103,
1,
203,
713,
]