From 1edee642195c496c5c85c013db116324b7f0fa37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Prus-Zaj=C4=85czkowski?= Date: Thu, 13 Aug 2026 19:13:52 +0200 Subject: [PATCH 1/2] Do not fail the refresh when every entity has its own scan_interval `async_update` polls only entities without a `scan_interval`. A device config that sets one on every entity leaves that list empty, `_update_device` returns {}, and the falsy check raised `UpdateFailed` on every single refresh. That sets last_update_success False, which marks ALL of the integration's entities unavailable - including the ones their own timers are polling perfectly well. The symptom is confusing: entities come up fine and then drop together about one refresh interval later, with nothing wrong on the bus. An empty poll list is not a failure. Return the data the per-entity timers have already stored instead of discarding it. Co-Authored-By: Claude Opus 5 (1M context) --- .../modbus_local_gateway/coordinator.py | 8 ++++ tests/test_coordinator.py | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/custom_components/modbus_local_gateway/coordinator.py b/custom_components/modbus_local_gateway/coordinator.py index a1d0e21..48da94a 100644 --- a/custom_components/modbus_local_gateway/coordinator.py +++ b/custom_components/modbus_local_gateway/coordinator.py @@ -280,6 +280,14 @@ async def async_update(self) -> dict[str, Any]: self.async_contexts(), key=lambda x: x.device_id ) entities = [ctx for ctx in entities if ctx.desc.scan_interval is None] + if not entities: + # Every entity has its own scan_interval and polls on its own timer, + # so there is nothing for the shared refresh to fetch. That is not a + # failure: raising here would set last_update_success False and mark + # every entity unavailable, including the ones polling perfectly well + # on their own. Keep whatever those timers have already stored. + _LOGGER.debug("No entities to refresh for %s", self.name) + return self.data or {} data: dict[str, Any] = await self._update_device(entities=entities) if data: return data diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index e462fe5..bf3a335 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -658,3 +658,48 @@ async def test_async_update_entity(mock_config_entry: ConfigEntry) -> None: coordinator._update_device.return_value = {"test2": "value3"} await coordinator.async_update_entity(ctx2) assert coordinator.data == {"test1": "value1", "test2": "value3"} + + +@pytest.mark.asyncio +async def test_async_update_with_no_coordinator_entities( + mock_config_entry: ConfigEntry, +) -> None: + """A device config where EVERY entity sets scan_interval must not fail. + + async_update only polls entities without a scan_interval. If a config gives + one to all of them that list is empty, and raising here would set + last_update_success False and mark every entity unavailable - including the + ones their own timers are polling perfectly well. + """ + client = MagicMock() + coordinator = ModbusCoordinator( + hass=MagicMock(), + config_entry=mock_config_entry, + gateway_device=MagicMock(), + client=client, + gateway="Test", + ) + + entities: list[ModbusContext] = [ + ModbusContext( + 1, + ModbusSensorEntityDescription( + register_address=1, + key="own_timer", + data_type=ModbusDataType.HOLDING_REGISTER, + scan_interval=10, + ), + ) + ] + # what the entity's own timer already stored, via async_update_entity + coordinator.data = {"own_timer": 42} + + with patch( + "custom_components.modbus_local_gateway.coordinator." + "ModbusCoordinator.async_contexts", + return_value=entities, + ): + result = await coordinator.async_update() + + assert result == {"own_timer": 42} # existing data preserved, not wiped + client.update_device.assert_not_called() # nothing was polled From eb9260c7655dedc52652a1134fe9987c800b01de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Prus-Zaj=C4=85czkowski?= Date: Tue, 25 Aug 2026 12:17:41 +0200 Subject: [PATCH 2/2] chore: remove unnecesary comment --- custom_components/modbus_local_gateway/coordinator.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/custom_components/modbus_local_gateway/coordinator.py b/custom_components/modbus_local_gateway/coordinator.py index 48da94a..3ce19e2 100644 --- a/custom_components/modbus_local_gateway/coordinator.py +++ b/custom_components/modbus_local_gateway/coordinator.py @@ -282,10 +282,7 @@ async def async_update(self) -> dict[str, Any]: entities = [ctx for ctx in entities if ctx.desc.scan_interval is None] if not entities: # Every entity has its own scan_interval and polls on its own timer, - # so there is nothing for the shared refresh to fetch. That is not a - # failure: raising here would set last_update_success False and mark - # every entity unavailable, including the ones polling perfectly well - # on their own. Keep whatever those timers have already stored. + # so there is nothing for the shared refresh to fetch. _LOGGER.debug("No entities to refresh for %s", self.name) return self.data or {} data: dict[str, Any] = await self._update_device(entities=entities)