diff --git a/custom_components/ninlight/const.py b/custom_components/ninlight/const.py index 9a181a6..fbce5c9 100644 --- a/custom_components/ninlight/const.py +++ b/custom_components/ninlight/const.py @@ -17,6 +17,9 @@ DEFAULT_SCAN_INTERVAL: Final = 30 MIN_SCAN_INTERVAL: Final = 10 MAX_SCAN_INTERVAL: Final = 300 +# Tolerate this many consecutive poll failures before marking the lamp unavailable. +MAX_POLL_FAILURES: Final = 3 + MAX_TRANSITION_MS: Final = 60_000 FIRMWARE_DIR_NAME: Final = "ninlight_firmware" diff --git a/custom_components/ninlight/coordinator.py b/custom_components/ninlight/coordinator.py index f46ad1d..978bae3 100644 --- a/custom_components/ninlight/coordinator.py +++ b/custom_components/ninlight/coordinator.py @@ -21,7 +21,7 @@ from .api import ( NinlightError, NinlightRequestError, ) -from .const import CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, DOMAIN, MAX_POLL_FAILURES _LOGGER = logging.getLogger(__name__) @@ -58,6 +58,8 @@ class NinlightCoordinator(DataUpdateCoordinator[NinlightData]): self.lock = asyncio.Lock() # While a firmware update runs, the update entity watches the lamp itself. self.ota_in_progress = False + # Consecutive poll failures; a short outage should not flip the lamp unavailable. + self._consecutive_poll_failures = 0 async def _async_update_data(self) -> NinlightData: if self.ota_in_progress and self.data is not None: @@ -69,7 +71,18 @@ class NinlightCoordinator(DataUpdateCoordinator[NinlightData]): except NinlightAuthError as err: raise ConfigEntryAuthFailed(f"{self.client.host}: token rejected") from err except NinlightError as err: + self._consecutive_poll_failures += 1 + if self.data is not None and self._consecutive_poll_failures < MAX_POLL_FAILURES: + _LOGGER.debug( + "%s: poll failed (%d/%d), keeping last known state: %s", + self.client.host, + self._consecutive_poll_failures, + MAX_POLL_FAILURES, + err, + ) + return self.data raise UpdateFailed(f"{self.client.host}: {err}") from err + self._consecutive_poll_failures = 0 return NinlightData(state, info) async def async_call[T](self, action: Callable[[NinlightClient], Awaitable[T]]) -> T: @@ -100,4 +113,5 @@ class NinlightCoordinator(DataUpdateCoordinator[NinlightData]): async def async_set_state(self, **fields: Any) -> None: """Write and adopt the lamp's complete answer directly - no debounced refresh.""" state = await self.async_call(lambda client: client.set_state(**fields)) + self._consecutive_poll_failures = 0 self.async_set_updated_data(NinlightData(state, self.data.info)) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 8a149dc..61c65cf 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -2,12 +2,18 @@ from __future__ import annotations +from copy import deepcopy from datetime import timedelta from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_ON +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_ON, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError import pytest @@ -19,6 +25,7 @@ from custom_components.ninlight.api import ( NinlightRequestError, ) from tests.helpers import entity_id_for +from tests_unit.samples import STATE_RGB @pytest.mark.parametrize( @@ -48,6 +55,112 @@ async def test_write_errors_are_translated( assert raised.value.translation_placeholders == placeholders +async def test_first_and_second_consecutive_poll_failures_keep_lamp_available( + hass: HomeAssistant, init_integration, mock_client: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + entity_id = entity_id_for(hass, "light", "light") + mock_client.get_state.side_effect = NinlightConnectionError("down") + + reads = mock_client.get_state.await_count + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_client.get_state.await_count == reads + 1 + assert hass.states.get(entity_id).state == STATE_ON + + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_client.get_state.await_count == reads + 2 + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_third_consecutive_poll_failure_makes_lamp_unavailable( + hass: HomeAssistant, init_integration, mock_client: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + entity_id = entity_id_for(hass, "light", "light") + mock_client.get_state.side_effect = NinlightConnectionError("down") + + for _ in range(3): + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_successful_poll_resets_failure_counter( + hass: HomeAssistant, init_integration, mock_client: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + entity_id = entity_id_for(hass, "light", "light") + mock_client.get_state.side_effect = NinlightConnectionError("down") + + # Two failures tolerated. + for _ in range(2): + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_ON + + # A successful poll resets the counter. + mock_client.get_state.side_effect = lambda: deepcopy(STATE_RGB) + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_ON + + # Two more failures in a row must still be tolerated. + mock_client.get_state.side_effect = NinlightConnectionError("down") + for _ in range(2): + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_turn_on_reaches_lamp_during_a_tolerated_poll_failure( + hass: HomeAssistant, init_integration, mock_client: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + entity_id = entity_id_for(hass, "light", "light") + mock_client.get_state.side_effect = NinlightConnectionError("down") + + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_client.set_state.assert_awaited_with(on=True) + + +async def test_successful_write_makes_unavailable_lamp_available_again( + hass: HomeAssistant, init_integration, mock_client: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + entity_id = entity_id_for(hass, "light", "light") + mock_client.get_state.side_effect = NinlightConnectionError("down") + + for _ in range(3): + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + await init_integration.runtime_data.async_set_state(on=True) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + # The counter was reset by the write: one more failure must still be tolerated. + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + async def test_polling_pauses_while_an_update_runs( hass: HomeAssistant, init_integration, mock_client: MagicMock, freezer: FrozenDateTimeFactory ) -> None: diff --git a/tests/test_light.py b/tests/test_light.py index 65a0d10..1952474 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -200,9 +200,10 @@ async def test_unreachable_lamp_becomes_unavailable_and_recovers( ) -> None: entity_id = entity_id_for(hass, "light", "light") mock_client.get_state.side_effect = NinlightConnectionError("down") - freezer.tick(timedelta(seconds=31)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + for _ in range(3): + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNAVAILABLE mock_client.get_state.side_effect = lambda: deepcopy(STATE_RGB)