From 7eb9fb65975b17cc2680cabafd09a661afd86364 Mon Sep 17 00:00:00 2001 From: farmio Date: Sat, 5 Sep 2026 22:32:06 +0200 Subject: [PATCH 1/5] Hand Fronius Modbus coordinators found by a re-scan to all platforms An inverter that was asleep, or one whose Modbus control was enabled after the integration was set up, gets its coordinator from the hourly re-scan. That reaches the platforms through SOLAR_NET_DISCOVERY_NEW, which only the sensor platform listens to - the number and switch platforms enumerated the coordinators once at setup, so the controls showed up only after Home Assistant was restarted. The sensor platform also built the wrong entity class for such a coordinator. It tells the two Modbus coordinators apart by the list they are in, and the dispatcher ran before the caller had put it there, so re-scanned MPPT sensors were given the unique_id of the SolarAPI inverter sensors. Co-Authored-By: Claude Opus 5 --- homeassistant/components/fronius/__init__.py | 20 ++++++--- homeassistant/components/fronius/number.py | 25 +++++++++-- homeassistant/components/fronius/switch.py | 25 +++++++++-- tests/components/fronius/test_modbus.py | 44 ++++++++++++++++++++ 4 files changed, 102 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index 2f65c6bb799c50..177b243e810b8b 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -41,6 +41,7 @@ FroniusInverterUpdateCoordinator, FroniusLoggerUpdateCoordinator, FroniusMeterUpdateCoordinator, + FroniusModbusCoordinatorBase, FroniusModbusInverterUpdateCoordinator, FroniusModbusSettingsUpdateCoordinator, FroniusOhmpilotUpdateCoordinator, @@ -377,8 +378,9 @@ async def _init_modbus_inverter(self, inverter_info: FroniusDeviceInfo) -> None: modbus_inverter=modbus_inverter, config_entry=self.config_entry, ) - if await self._start_modbus_coordinator(readings): - self.modbus_inverter_coordinators.append(readings) + await self._start_modbus_coordinator( + readings, self.modbus_inverter_coordinators + ) else: _LOGGER.debug( "No MPPT model exposed by inverter %s at Modbus unit %s", @@ -396,8 +398,9 @@ async def _init_modbus_inverter(self, inverter_info: FroniusDeviceInfo) -> None: modbus_inverter=modbus_inverter, config_entry=self.config_entry, ) - if await self._start_modbus_coordinator(settings): - self.modbus_settings_coordinators.append(settings) + await self._start_modbus_coordinator( + settings, self.modbus_settings_coordinators + ) _LOGGER.debug( "Modbus enabled for inverter %s (UID: %s, unit ID: %s)", @@ -406,8 +409,10 @@ async def _init_modbus_inverter(self, inverter_info: FroniusDeviceInfo) -> None: unit_id, ) - async def _start_modbus_coordinator( - self, coordinator: FroniusCoordinatorBase + async def _start_modbus_coordinator[ + _ModbusCoordinatorT: FroniusModbusCoordinatorBase + ]( + self, coordinator: _ModbusCoordinatorT, coordinators: list[_ModbusCoordinatorT] ) -> bool: """Do the first refresh of a Modbus coordinator, reporting success. @@ -418,6 +423,9 @@ async def _start_modbus_coordinator( await coordinator.async_refresh() if not coordinator.last_update_success: return False + # the platforms tell the coordinators apart by the list they are in, + # so it is kept before they are told about this one + coordinators.append(coordinator) # Only for re-scans. Initial setup adds entities through the # platforms' async_setup_entry. if self.config_entry.state is ConfigEntryState.LOADED: diff --git a/homeassistant/components/fronius/number.py b/homeassistant/components/fronius/number.py index 303566c263da15..0307e7d2fbfad7 100644 --- a/homeassistant/components/fronius/number.py +++ b/homeassistant/components/fronius/number.py @@ -5,14 +5,19 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import PERCENTAGE, EntityCategory, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import SOLAR_NET_DISCOVERY_NEW from .entity import FroniusEntity, FroniusEntityDescription, ModbusComponentFn if TYPE_CHECKING: from . import FroniusConfigEntry - from .coordinator import FroniusModbusSettingsUpdateCoordinator + from .coordinator import ( + FroniusCoordinatorBase, + FroniusModbusSettingsUpdateCoordinator, + ) # writes go to one device at a time PARALLEL_UPDATES: Final = 1 @@ -84,11 +89,25 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Fronius number entities based on a config entry.""" - for coordinator in config_entry.runtime_data.modbus_settings_coordinators: + solar_net = config_entry.runtime_data + for coordinator in solar_net.modbus_settings_coordinators: coordinator.add_entities_for_seen_keys( async_add_entities, Platform.NUMBER, ModbusSetpointNumber ) + @callback + def async_add_new_entities(coordinator: FroniusCoordinatorBase) -> None: + """Add the entities of a coordinator found after setup.""" + if coordinator not in solar_net.modbus_settings_coordinators: + return + coordinator.add_entities_for_seen_keys( + async_add_entities, Platform.NUMBER, ModbusSetpointNumber + ) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SOLAR_NET_DISCOVERY_NEW, async_add_new_entities) + ) + class ModbusSetpointNumber(FroniusEntity, NumberEntity): """A writable setpoint of an inverters Modbus interface.""" diff --git a/homeassistant/components/fronius/switch.py b/homeassistant/components/fronius/switch.py index 5a1381621c10a7..1d8c5131e5b3f4 100644 --- a/homeassistant/components/fronius/switch.py +++ b/homeassistant/components/fronius/switch.py @@ -5,14 +5,19 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import SOLAR_NET_DISCOVERY_NEW from .entity import FroniusEntity, FroniusEntityDescription, ModbusComponentFn if TYPE_CHECKING: from . import FroniusConfigEntry - from .coordinator import FroniusModbusSettingsUpdateCoordinator + from .coordinator import ( + FroniusCoordinatorBase, + FroniusModbusSettingsUpdateCoordinator, + ) # writes go to one device at a time PARALLEL_UPDATES: Final = 1 @@ -63,11 +68,25 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Fronius switch entities based on a config entry.""" - for coordinator in config_entry.runtime_data.modbus_settings_coordinators: + solar_net = config_entry.runtime_data + for coordinator in solar_net.modbus_settings_coordinators: coordinator.add_entities_for_seen_keys( async_add_entities, Platform.SWITCH, ModbusControlSwitch ) + @callback + def async_add_new_entities(coordinator: FroniusCoordinatorBase) -> None: + """Add the entities of a coordinator found after setup.""" + if coordinator not in solar_net.modbus_settings_coordinators: + return + coordinator.add_entities_for_seen_keys( + async_add_entities, Platform.SWITCH, ModbusControlSwitch + ) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SOLAR_NET_DISCOVERY_NEW, async_add_new_entities) + ) + class ModbusControlSwitch(FroniusEntity, SwitchEntity): """A control of an inverters Modbus interface that is on or off. diff --git a/tests/components/fronius/test_modbus.py b/tests/components/fronius/test_modbus.py index 9806d210128190..bcf638d80d2569 100644 --- a/tests/components/fronius/test_modbus.py +++ b/tests/components/fronius/test_modbus.py @@ -386,6 +386,7 @@ async def test_modbus_retried_after_setup( aioclient_mock: AiohttpClientMocker, mock_modbus_unavailable: MagicMock, mock_modbus_connection: MockModbusConnection, + entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test an inverter asleep at setup time gets its Modbus entities later. @@ -416,6 +417,10 @@ async def test_modbus_retried_after_setup( assert config_entry.runtime_data.modbus_inverter_coordinators assert_state(hass, "sensor.gen24_storage_mppt_1_dc_power", 3300) + # the Modbus sensors of the re-scan are told apart from the SolarAPI ones + entry = entity_registry.async_get("sensor.gen24_storage_mppt_1_dc_power") + assert entry + assert "-modbus-" in entry.unique_id # the hold on the shared connection is taken once, not once per re-scan assert mock_modbus_unavailable.call_count == 1 @@ -451,3 +456,42 @@ async def test_control_refused_creates_no_control_entities( ) if entry.domain == "number" ] + + +async def test_controls_enabled_later_get_their_entities( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_fronius_modbus: MockModbusConnection, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entities appear for controls a re-scan finds after setup. + + The platforms are set up once, so a coordinator that only comes up on a + later re-scan has to be handed to them through the dispatcher. + """ + mock_fronius_modbus.for_unit(1).holding.update( + build_sunspec_map([], include_mppt_model=False) + ) + mock_responses(aioclient_mock, fixture_set="gen24_storage") + with ( + patch( + "homeassistant.components.fronius.PLATFORMS", + [Platform.NUMBER, Platform.SWITCH], + ), + patch( + "fronius_modbus.Controls.probe_write_access", AsyncMock(return_value=False) + ), + ): + config_entry = await setup_fronius_integration( + hass, is_logger=False, unique_id="12345678" + ) + assert hass.states.get("number.gen24_storage_ac_power_limit") is None + + # inverter control via Modbus is enabled on the device web interface + freezer.tick(timedelta(minutes=SOLAR_NET_RESCAN_TIMER, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert config_entry.runtime_data.modbus_settings_coordinators + assert hass.states.get("number.gen24_storage_ac_power_limit") + assert hass.states.get("switch.gen24_storage_ac_power_limiting") From ecc2dc2f91e9fb7c0ba72412c6f80fefb26d6e40 Mon Sep 17 00:00:00 2001 From: farmio Date: Sat, 5 Sep 2026 22:58:07 +0200 Subject: [PATCH 2/5] Retry the Fronius Modbus coordinators one by one Both are set up from the same re-scan, but the guard only looked at the readings coordinators: an inverter with an MPPT model never reached the settings path again, so enabling inverter control on the device needed a restart of Home Assistant - while a controls-only inverter satisfied the guard on every scan and piled up another settings coordinator each hour. With the platforms now listening, that pile turned into "does not generate unique IDs" errors. The dispatcher reaches every platform, including the ones a coordinator has nothing for, where indexing its descriptions raised a KeyError. Each platform now takes only the coordinators that carry its descriptions. Co-Authored-By: Claude Opus 5 --- homeassistant/components/fronius/__init__.py | 19 +++++-- homeassistant/components/fronius/number.py | 2 +- homeassistant/components/fronius/sensor.py | 2 + homeassistant/components/fronius/switch.py | 2 +- tests/components/fronius/test_modbus.py | 59 +++++++++++++++++--- 5 files changed, 68 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index 177b243e810b8b..b7f67b147ebfdf 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -337,10 +337,17 @@ def _modbus_params(self) -> ModbusTcpParams | None: async def _init_modbus_inverter(self, inverter_info: FroniusDeviceInfo) -> None: """Set up a Modbus coordinator for an inverter exposing SunSpec MPPT data.""" - if inverter_info.solar_net_id in [ + # each coordinator is retried on its own: a device may answer for one + # of them and not the other, and recover on a later re-scan + needs_readings = inverter_info.solar_net_id not in { coordinator.inverter_info.solar_net_id for coordinator in self.modbus_inverter_coordinators - ]: + } + needs_settings = inverter_info.solar_net_id not in { + coordinator.inverter_info.solar_net_id + for coordinator in self.modbus_settings_coordinators + } + if not needs_readings and not needs_settings: return if (unit_id := self._modbus_unit_id(inverter_info.solar_net_id)) is None: return @@ -368,7 +375,7 @@ async def _init_modbus_inverter(self, inverter_info: FroniusDeviceInfo) -> None: err, ) return - if modbus_inverter.mppt is not None: + if needs_readings and modbus_inverter.mppt is not None: readings = FroniusModbusInverterUpdateCoordinator( hass=self.hass, solar_net=self, @@ -381,14 +388,16 @@ async def _init_modbus_inverter(self, inverter_info: FroniusDeviceInfo) -> None: await self._start_modbus_coordinator( readings, self.modbus_inverter_coordinators ) - else: + elif needs_readings: _LOGGER.debug( "No MPPT model exposed by inverter %s at Modbus unit %s", inverter_info.solar_net_id, unit_id, ) - if await self._modbus_control_allowed(modbus_inverter, unit_id): + if needs_settings and await self._modbus_control_allowed( + modbus_inverter, unit_id + ): settings = FroniusModbusSettingsUpdateCoordinator( hass=self.hass, solar_net=self, diff --git a/homeassistant/components/fronius/number.py b/homeassistant/components/fronius/number.py index 0307e7d2fbfad7..8e3edf4f126f5d 100644 --- a/homeassistant/components/fronius/number.py +++ b/homeassistant/components/fronius/number.py @@ -98,7 +98,7 @@ async def async_setup_entry( @callback def async_add_new_entities(coordinator: FroniusCoordinatorBase) -> None: """Add the entities of a coordinator found after setup.""" - if coordinator not in solar_net.modbus_settings_coordinators: + if Platform.NUMBER not in coordinator.valid_descriptions: return coordinator.add_entities_for_seen_keys( async_add_entities, Platform.NUMBER, ModbusSetpointNumber diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index ad3831f3691427..508edebd145963 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -101,6 +101,8 @@ async def async_setup_entry( @callback def async_add_new_entities(coordinator: FroniusCoordinatorBase) -> None: """Add newly found inverter entities.""" + if Platform.SENSOR not in coordinator.valid_descriptions: + return constructor = ( ModbusInverterSensor if coordinator in solar_net.modbus_inverter_coordinators diff --git a/homeassistant/components/fronius/switch.py b/homeassistant/components/fronius/switch.py index 1d8c5131e5b3f4..b3229396d44fe6 100644 --- a/homeassistant/components/fronius/switch.py +++ b/homeassistant/components/fronius/switch.py @@ -77,7 +77,7 @@ async def async_setup_entry( @callback def async_add_new_entities(coordinator: FroniusCoordinatorBase) -> None: """Add the entities of a coordinator found after setup.""" - if coordinator not in solar_net.modbus_settings_coordinators: + if Platform.SWITCH not in coordinator.valid_descriptions: return coordinator.add_entities_for_seen_keys( async_add_entities, Platform.SWITCH, ModbusControlSwitch diff --git a/tests/components/fronius/test_modbus.py b/tests/components/fronius/test_modbus.py index bcf638d80d2569..5912a45441a24a 100644 --- a/tests/components/fronius/test_modbus.py +++ b/tests/components/fronius/test_modbus.py @@ -1,10 +1,13 @@ """Tests for the Fronius Modbus TCP (SunSpec) support.""" from datetime import timedelta +from logging import ERROR from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory +from fronius_modbus import Mppt from fronius_modbus.testing import MpptModuleSpec, build_sunspec_map +from modbus_connection import ModbusConnectionError from modbus_connection.mock import MockModbusConnection import pytest @@ -250,6 +253,7 @@ async def test_no_mppt_model( aioclient_mock: AiohttpClientMocker, mock_fronius_modbus: MockModbusConnection, entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Test a SunSpec device without MPPT model still gets its controls. @@ -277,6 +281,13 @@ async def test_no_mppt_model( assert not [entry for entry in modbus_entities if "mppt" in entry.unique_id] assert "number" in {entry.domain for entry in modbus_entities} + freezer.tick(timedelta(minutes=SOLAR_NET_RESCAN_TIMER, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # the re-scan finds the controls already set up + assert len(config_entry.runtime_data.modbus_settings_coordinators) == 1 + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_not_implemented_values( @@ -463,24 +474,20 @@ async def test_controls_enabled_later_get_their_entities( aioclient_mock: AiohttpClientMocker, mock_fronius_modbus: MockModbusConnection, freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test entities appear for controls a re-scan finds after setup. The platforms are set up once, so a coordinator that only comes up on a - later re-scan has to be handed to them through the dispatcher. + later re-scan has to be handed to them through the dispatcher - which + every platform listens to, including those it has nothing for. """ mock_fronius_modbus.for_unit(1).holding.update( build_sunspec_map([], include_mppt_model=False) ) mock_responses(aioclient_mock, fixture_set="gen24_storage") - with ( - patch( - "homeassistant.components.fronius.PLATFORMS", - [Platform.NUMBER, Platform.SWITCH], - ), - patch( - "fronius_modbus.Controls.probe_write_access", AsyncMock(return_value=False) - ), + with patch( + "fronius_modbus.Controls.probe_write_access", AsyncMock(return_value=False) ): config_entry = await setup_fronius_integration( hass, is_logger=False, unique_id="12345678" @@ -495,3 +502,37 @@ async def test_controls_enabled_later_get_their_entities( assert config_entry.runtime_data.modbus_settings_coordinators assert hass.states.get("number.gen24_storage_ac_power_limit") assert hass.states.get("switch.gen24_storage_ac_power_limiting") + assert not [record for record in caplog.records if record.levelno >= ERROR] + + +async def test_readings_recover_when_only_the_controls_came_up( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_fronius_modbus: MockModbusConnection, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a re-scan still adds the MPPT data after it failed once. + + The two coordinators are independent: one of them answering is no reason + to stop retrying the other. + """ + mock_fronius_modbus.for_unit(1).holding.update( + build_sunspec_map(GEN24_HYBRID_MODULES, storage_wcha_max=12800) + ) + mock_responses(aioclient_mock, fixture_set="gen24_storage") + with patch.object( + Mppt, "async_update", side_effect=ModbusConnectionError("no answer") + ): + config_entry = await setup_fronius_integration( + hass, is_logger=False, unique_id="12345678" + ) + assert not config_entry.runtime_data.modbus_inverter_coordinators + assert config_entry.runtime_data.modbus_settings_coordinators + + freezer.tick(timedelta(minutes=SOLAR_NET_RESCAN_TIMER, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert config_entry.runtime_data.modbus_inverter_coordinators + # the settings coordinator that was already up is not added a second time + assert len(config_entry.runtime_data.modbus_settings_coordinators) == 1 From 8e7e3de674894815d9efa7c5eaa3608a0becc184 Mon Sep 17 00:00:00 2001 From: farmio Date: Sat, 5 Sep 2026 23:04:46 +0200 Subject: [PATCH 3/5] Move Fronius Modbus sensors 2026.9 registered as SolarAPI ones Those entities carry `-` instead of `-modbus-`, so the fixed platform would register the entity again and leave the one holding the history behind. The key sets of the two do not overlap, so a Modbus key without the marker can only come from that, and an entity whose place is already taken is left alone. Co-Authored-By: Claude Opus 5 --- homeassistant/components/fronius/__init__.py | 34 ++++++++- tests/components/fronius/test_modbus.py | 74 ++++++++++++++++++-- 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index b7f67b147ebfdf..d4cf58c64ab1f6 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -18,9 +18,9 @@ from homeassistant.components.modbus import async_get_unit from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION, CONF_HOST, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -48,6 +48,7 @@ FroniusPowerFlowUpdateCoordinator, FroniusStorageUpdateCoordinator, ) +from .sensor import MODBUS_INVERTER_ENTITY_DESCRIPTIONS _LOGGER: Final = logging.getLogger(__name__) PLATFORMS: Final = [ @@ -59,9 +60,38 @@ type FroniusConfigEntry = ConfigEntry[FroniusSolarNet] +MODBUS_SENSOR_KEYS: Final = { + description.key for description in MODBUS_INVERTER_ENTITY_DESCRIPTIONS +} + + +@callback +def _async_fix_modbus_sensor_unique_ids( + hass: HomeAssistant, entry: FroniusConfigEntry +) -> None: + """Move sensors that were registered with the SolarAPI unique ID format. + + A Modbus coordinator found by a re-scan reached the sensor platform + before it could be told from a SolarAPI one, so 2026.9 built its entities + as SolarAPI ones: `-` instead of `-modbus-`. + The keys of the two are distinct, so a Modbus key without the marker can + only come from that. + """ + registry = er.async_get(hass) + for entity in er.async_entries_for_config_entry(registry, entry.entry_id): + inverter_id, _, key = entity.unique_id.rpartition("-") + if key not in MODBUS_SENSOR_KEYS or inverter_id.endswith("-modbus"): + continue + unique_id = f"{inverter_id}-modbus-{key}" + if registry.async_get_entity_id(entity.domain, DOMAIN, unique_id): + continue + _LOGGER.debug("Migrating unique ID of %s to %s", entity.entity_id, unique_id) + registry.async_update_entity(entity.entity_id, new_unique_id=unique_id) + async def async_setup_entry(hass: HomeAssistant, entry: FroniusConfigEntry) -> bool: """Set up fronius from a config entry.""" + _async_fix_modbus_sensor_unique_ids(hass, entry) host = entry.data[CONF_HOST] fronius = Fronius( async_get_clientsession( diff --git a/tests/components/fronius/test_modbus.py b/tests/components/fronius/test_modbus.py index 5912a45441a24a..1f191ff853d159 100644 --- a/tests/components/fronius/test_modbus.py +++ b/tests/components/fronius/test_modbus.py @@ -11,18 +11,18 @@ from modbus_connection.mock import MockModbusConnection import pytest -from homeassistant.components.fronius.const import SOLAR_NET_RESCAN_TIMER +from homeassistant.components.fronius.const import DOMAIN, SOLAR_NET_RESCAN_TIMER from homeassistant.components.fronius.coordinator import ( FroniusModbusInverterUpdateCoordinator, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import mock_responses, setup_fronius_integration +from . import MOCK_HOST, mock_responses, setup_fronius_integration -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker # module names as reported by real GEN24 hybrid inverters @@ -536,3 +536,69 @@ async def test_readings_recover_when_only_the_controls_came_up( assert config_entry.runtime_data.modbus_inverter_coordinators # the settings coordinator that was already up is not added a second time assert len(config_entry.runtime_data.modbus_settings_coordinators) == 1 + + +async def test_wrongly_registered_sensors_are_moved_over( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_fronius_modbus: MockModbusConnection, + entity_registry: er.EntityRegistry, +) -> None: + """Test 2026.9 Modbus sensors keep their entity ID and history. + + A re-scan registered them with the SolarAPI unique ID format, which the + fixed platform would otherwise leave behind as a stale entity. + """ + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="f1e2b9837e8adaed6fa682acaa216fd8", + unique_id="12345678", + data={CONF_HOST: MOCK_HOST, "is_logger": False, "modbus_port": 502}, + minor_version=2, + ) + config_entry.add_to_hass(hass) + stale = entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "12345678-mppt_1_power_dc", + config_entry=config_entry, + suggested_object_id="gen24_storage_mppt_1_dc_power", + ) + untouched = entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "12345678-energy_total", + config_entry=config_entry, + suggested_object_id="gen24_storage_total_energy", + ) + # a restart has already registered a second entity for this one + superseded = entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "12345678-mppt_2_power_dc", + config_entry=config_entry, + suggested_object_id="gen24_storage_mppt_2_dc_power_old", + ) + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + "12345678-modbus-mppt_2_power_dc", + config_entry=config_entry, + suggested_object_id="gen24_storage_mppt_2_dc_power", + ) + mock_fronius_modbus.for_unit(1).holding.update( + build_sunspec_map(GEN24_HYBRID_MODULES, storage_wcha_max=12800) + ) + mock_responses(aioclient_mock, fixture_set="gen24_storage") + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert (entry := entity_registry.async_get(stale.entity_id)) + assert entry.unique_id == "12345678-modbus-mppt_1_power_dc" + # a SolarAPI sensor keeps its own format + assert (entry := entity_registry.async_get(untouched.entity_id)) + assert entry.unique_id == "12345678-energy_total" + # and one whose place is taken is left where it is + assert (entry := entity_registry.async_get(superseded.entity_id)) + assert entry.unique_id == "12345678-mppt_2_power_dc" From a09739f9f24a5ef6afad685b05e9f654c6a2a8d7 Mon Sep 17 00:00:00 2001 From: farmio Date: Sat, 5 Sep 2026 23:16:26 +0200 Subject: [PATCH 4/5] Give each Fronius config entry its own discovery signal The signal for coordinators found after setup was one string for the whole integration, so with two Fronius devices configured each entry's platforms were handed the other's coordinators - building entities on the wrong entry, and in the sensor platform with the wrong class, because it tells the coordinators apart by lists that belong to one entry. Co-Authored-By: Claude Opus 5 --- homeassistant/components/fronius/__init__.py | 12 +++++++++--- homeassistant/components/fronius/const.py | 13 ++++++++++++- homeassistant/components/fronius/number.py | 6 ++++-- homeassistant/components/fronius/sensor.py | 4 ++-- homeassistant/components/fronius/switch.py | 6 ++++-- 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index d4cf58c64ab1f6..d2413f9934deca 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -30,11 +30,11 @@ CONF_MODBUS_PORT, DEFAULT_MODBUS_PORT, DOMAIN, - SOLAR_NET_DISCOVERY_NEW, SOLAR_NET_ID_SYSTEM, SOLAR_NET_RESCAN_TIMER, FroniusDeviceInfo, SolarNetId, + discovery_signal, ) from .coordinator import ( FroniusCoordinatorBase, @@ -291,7 +291,11 @@ async def _init_devices_inverter(self, _now: datetime | None = None) -> None: # Only for re-scans. Initial setup adds entities # through sensor.async_setup_entry if self.config_entry.state is ConfigEntryState.LOADED: - async_dispatcher_send(self.hass, SOLAR_NET_DISCOVERY_NEW, _coordinator) + async_dispatcher_send( + self.hass, + discovery_signal(self.config_entry.entry_id), + _coordinator, + ) _LOGGER.debug( "New inverter added (UID: %s)", @@ -468,7 +472,9 @@ async def _start_modbus_coordinator[ # Only for re-scans. Initial setup adds entities through the # platforms' async_setup_entry. if self.config_entry.state is ConfigEntryState.LOADED: - async_dispatcher_send(self.hass, SOLAR_NET_DISCOVERY_NEW, coordinator) + async_dispatcher_send( + self.hass, discovery_signal(self.config_entry.entry_id), coordinator + ) return True async def _modbus_control_allowed( diff --git a/homeassistant/components/fronius/const.py b/homeassistant/components/fronius/const.py index 22934207d1c5e7..00e27916cc402f 100644 --- a/homeassistant/components/fronius/const.py +++ b/homeassistant/components/fronius/const.py @@ -12,7 +12,18 @@ DEFAULT_MODBUS_PORT: Final = 502 type SolarNetId = str -SOLAR_NET_DISCOVERY_NEW: Final = "fronius_discovery_new" +_SOLAR_NET_DISCOVERY_NEW: Final = "fronius_discovery_new" + + +def discovery_signal(entry_id: str) -> str: + """Return the signal carrying coordinators of an entry found after setup. + + One signal per config entry: a device found by one entry's re-scan has + nothing to do with the platforms of another. + """ + return f"{_SOLAR_NET_DISCOVERY_NEW}_{entry_id}" + + SOLAR_NET_ID_POWER_FLOW: SolarNetId = "power_flow" SOLAR_NET_ID_SYSTEM: SolarNetId = "system" SOLAR_NET_RESCAN_TIMER: Final = 60 diff --git a/homeassistant/components/fronius/number.py b/homeassistant/components/fronius/number.py index 8e3edf4f126f5d..4b7051f0233f73 100644 --- a/homeassistant/components/fronius/number.py +++ b/homeassistant/components/fronius/number.py @@ -9,7 +9,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import SOLAR_NET_DISCOVERY_NEW +from .const import discovery_signal from .entity import FroniusEntity, FroniusEntityDescription, ModbusComponentFn if TYPE_CHECKING: @@ -105,7 +105,9 @@ def async_add_new_entities(coordinator: FroniusCoordinatorBase) -> None: ) config_entry.async_on_unload( - async_dispatcher_connect(hass, SOLAR_NET_DISCOVERY_NEW, async_add_new_entities) + async_dispatcher_connect( + hass, discovery_signal(config_entry.entry_id), async_add_new_entities + ) ) diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 508edebd145963..9d37a4aba89c49 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -34,10 +34,10 @@ from .const import ( DOMAIN, INVERTER_ERROR_CODES, - SOLAR_NET_DISCOVERY_NEW, InverterStatusCodeOption, MeterLocationCodeOption, OhmPilotStateCodeOption, + discovery_signal, get_inverter_status_message, get_meter_location_description, get_ohmpilot_state_message, @@ -115,7 +115,7 @@ def async_add_new_entities(coordinator: FroniusCoordinatorBase) -> None: config_entry.async_on_unload( async_dispatcher_connect( hass, - SOLAR_NET_DISCOVERY_NEW, + discovery_signal(config_entry.entry_id), async_add_new_entities, ) ) diff --git a/homeassistant/components/fronius/switch.py b/homeassistant/components/fronius/switch.py index b3229396d44fe6..42ec94efb496c4 100644 --- a/homeassistant/components/fronius/switch.py +++ b/homeassistant/components/fronius/switch.py @@ -9,7 +9,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import SOLAR_NET_DISCOVERY_NEW +from .const import discovery_signal from .entity import FroniusEntity, FroniusEntityDescription, ModbusComponentFn if TYPE_CHECKING: @@ -84,7 +84,9 @@ def async_add_new_entities(coordinator: FroniusCoordinatorBase) -> None: ) config_entry.async_on_unload( - async_dispatcher_connect(hass, SOLAR_NET_DISCOVERY_NEW, async_add_new_entities) + async_dispatcher_connect( + hass, discovery_signal(config_entry.entry_id), async_add_new_entities + ) ) From df2bf3df768bbcfceb14058d0dec878236a722c3 Mon Sep 17 00:00:00 2001 From: farmio Date: Sun, 6 Sep 2026 08:05:15 +0200 Subject: [PATCH 5/5] Cover the inverter that already has its readings coordinator Without an MPPT model the readings list stays empty, so the old guard would have retried the controls anyway - the case it actually blocked is an inverter that has both. Co-Authored-By: Claude Opus 5 --- tests/components/fronius/test_modbus.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/components/fronius/test_modbus.py b/tests/components/fronius/test_modbus.py index 1f191ff853d159..c8c2c6fc4d023c 100644 --- a/tests/components/fronius/test_modbus.py +++ b/tests/components/fronius/test_modbus.py @@ -469,21 +469,34 @@ async def test_control_refused_creates_no_control_entities( ] +@pytest.mark.parametrize( + ("modules", "include_mppt_model"), + [ + pytest.param(GEN24_HYBRID_MODULES, True, id="mppt_model"), + pytest.param([], False, id="no_mppt_model"), + ], +) async def test_controls_enabled_later_get_their_entities( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_fronius_modbus: MockModbusConnection, freezer: FrozenDateTimeFactory, caplog: pytest.LogCaptureFixture, + modules: list[MpptModuleSpec], + include_mppt_model: bool, ) -> None: """Test entities appear for controls a re-scan finds after setup. The platforms are set up once, so a coordinator that only comes up on a later re-scan has to be handed to them through the dispatcher - which - every platform listens to, including those it has nothing for. + every platform listens to, including those it has nothing for. Whether + the device also has an MPPT model decides whether a readings coordinator + is already there when the controls arrive. """ mock_fronius_modbus.for_unit(1).holding.update( - build_sunspec_map([], include_mppt_model=False) + build_sunspec_map( + modules, include_mppt_model=include_mppt_model, storage_wcha_max=12800 + ) ) mock_responses(aioclient_mock, fixture_set="gen24_storage") with patch(