diff --git a/homeassistant/components/solaredge_modbus/diagnostics.py b/homeassistant/components/solaredge_modbus/diagnostics.py index 925f12234efd..d77f036fd14c 100644 --- a/homeassistant/components/solaredge_modbus/diagnostics.py +++ b/homeassistant/components/solaredge_modbus/diagnostics.py @@ -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), } diff --git a/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr b/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr index c96d5cbea1b7..f587b6c8bf2d 100644 --- a/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr +++ b/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr @@ -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([ ]), }) diff --git a/tests/components/solaredge_modbus/test_diagnostics.py b/tests/components/solaredge_modbus/test_diagnostics.py index 980bdfd91550..89ff91cee397 100644 --- a/tests/components/solaredge_modbus/test_diagnostics.py +++ b/tests/components/solaredge_modbus/test_diagnostics.py @@ -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, + ]