fix(coordinator): kurze Aussetzer machen die Lampe nicht unbedienbar

This commit is contained in:
Michael Sauer
2026-09-16 18:38:45 +02:00
parent fc29b0befe
commit 1026f427ca
4 changed files with 136 additions and 5 deletions
+3
View File
@@ -17,6 +17,9 @@ DEFAULT_SCAN_INTERVAL: Final = 30
MIN_SCAN_INTERVAL: Final = 10 MIN_SCAN_INTERVAL: Final = 10
MAX_SCAN_INTERVAL: Final = 300 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 MAX_TRANSITION_MS: Final = 60_000
FIRMWARE_DIR_NAME: Final = "ninlight_firmware" FIRMWARE_DIR_NAME: Final = "ninlight_firmware"
+15 -1
View File
@@ -21,7 +21,7 @@ from .api import (
NinlightError, NinlightError,
NinlightRequestError, 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__) _LOGGER = logging.getLogger(__name__)
@@ -58,6 +58,8 @@ class NinlightCoordinator(DataUpdateCoordinator[NinlightData]):
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
# While a firmware update runs, the update entity watches the lamp itself. # While a firmware update runs, the update entity watches the lamp itself.
self.ota_in_progress = False 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: async def _async_update_data(self) -> NinlightData:
if self.ota_in_progress and self.data is not None: if self.ota_in_progress and self.data is not None:
@@ -69,7 +71,18 @@ class NinlightCoordinator(DataUpdateCoordinator[NinlightData]):
except NinlightAuthError as err: except NinlightAuthError as err:
raise ConfigEntryAuthFailed(f"{self.client.host}: token rejected") from err raise ConfigEntryAuthFailed(f"{self.client.host}: token rejected") from err
except NinlightError as 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 raise UpdateFailed(f"{self.client.host}: {err}") from err
self._consecutive_poll_failures = 0
return NinlightData(state, info) return NinlightData(state, info)
async def async_call[T](self, action: Callable[[NinlightClient], Awaitable[T]]) -> T: 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: async def async_set_state(self, **fields: Any) -> None:
"""Write and adopt the lamp's complete answer directly - no debounced refresh.""" """Write and adopt the lamp's complete answer directly - no debounced refresh."""
state = await self.async_call(lambda client: client.set_state(**fields)) 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)) self.async_set_updated_data(NinlightData(state, self.data.info))
+114 -1
View File
@@ -2,12 +2,18 @@
from __future__ import annotations from __future__ import annotations
from copy import deepcopy
from datetime import timedelta from datetime import timedelta
from unittest.mock import MagicMock from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN 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.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
import pytest import pytest
@@ -19,6 +25,7 @@ from custom_components.ninlight.api import (
NinlightRequestError, NinlightRequestError,
) )
from tests.helpers import entity_id_for from tests.helpers import entity_id_for
from tests_unit.samples import STATE_RGB
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -48,6 +55,112 @@ async def test_write_errors_are_translated(
assert raised.value.translation_placeholders == placeholders 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( async def test_polling_pauses_while_an_update_runs(
hass: HomeAssistant, init_integration, mock_client: MagicMock, freezer: FrozenDateTimeFactory hass: HomeAssistant, init_integration, mock_client: MagicMock, freezer: FrozenDateTimeFactory
) -> None: ) -> None:
+4 -3
View File
@@ -200,9 +200,10 @@ async def test_unreachable_lamp_becomes_unavailable_and_recovers(
) -> None: ) -> None:
entity_id = entity_id_for(hass, "light", "light") entity_id = entity_id_for(hass, "light", "light")
mock_client.get_state.side_effect = NinlightConnectionError("down") mock_client.get_state.side_effect = NinlightConnectionError("down")
freezer.tick(timedelta(seconds=31)) for _ in range(3):
async_fire_time_changed(hass) freezer.tick(timedelta(seconds=31))
await hass.async_block_till_done() async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
mock_client.get_state.side_effect = lambda: deepcopy(STATE_RGB) mock_client.get_state.side_effect = lambda: deepcopy(STATE_RGB)