From b519f70908aac2e90341274e1a088dd04b05813f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 8 Dec 2025 10:04:54 +0000 Subject: [PATCH 1/4] Keep consistent 24h firmware check scheduale --- homeassistant/components/reolink/__init__.py | 39 +++++++++++++++++--- homeassistant/components/reolink/host.py | 1 + 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 0a4c88124e8d23..b0d657ecd2e314 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -4,7 +4,7 @@ import asyncio from collections.abc import Callable -from datetime import timedelta +from datetime import UTC, datetime, timedelta import logging from time import time from typing import Any @@ -138,6 +138,9 @@ async def async_setup_entry( } hass.config_entries.async_update_entry(config_entry, data=data) + # retrieve the firmware store to keep track of the last time the firmware was checked + firmware_store = get_store(hass, f"{config_entry.entry_id}_firmware") + min_timeout = host.api.timeout * (RETRY_ATTEMPTS + 2) update_timeout = max(min_timeout, min_timeout * host.api.num_cameras / 10) @@ -194,6 +197,7 @@ async def async_check_firmware_update() -> None: ) from err finally: host.starting = False + await firmware_store.async_save(datetime.now(UTC).isoformat()) device_coordinator = DataUpdateCoordinator( hass, @@ -212,15 +216,36 @@ async def async_check_firmware_update() -> None: config_entry=config_entry, name=f"reolink.{host.api.nvr_name}.firmware", update_method=async_check_firmware_update, - update_interval=FIRMWARE_UPDATE_INTERVAL, + update_interval=None, # Do not fetch data automatically, resume 24h schedule ) + async def first_firmware_check(*args: Any) -> None: + """Start first firmware check delayed to continue 24h schedule.""" + firmware_coordinator.update_interval = FIRMWARE_UPDATE_INTERVAL + await firmware_coordinator.async_refresh() + host.cancel_first_firmware_check = None + # If camera WAN blocked, firmware check fails and takes long, do not prevent setup - config_entry.async_create_background_task( - hass, - firmware_coordinator.async_refresh(), - f"Reolink firmware check {config_entry.entry_id}", + firmware_check_delay: int | timedelta = 5 + last_check = await firmware_store.async_load() + if last_check is not None: + firmware_check_delay = FIRMWARE_UPDATE_INTERVAL - ( + datetime.now(UTC) - datetime.fromisoformat(last_check) + ) + if ( + firmware_check_delay < timedelta(0) + or firmware_check_delay > FIRMWARE_UPDATE_INTERVAL + ): + firmware_check_delay = 5 + _LOGGER.debug( + "Scheduling first Reolink %s firmware check in %s", + host.api.nvr_name, + firmware_check_delay, ) + host.cancel_first_firmware_check = async_call_later( + hass, firmware_check_delay, first_firmware_check + ) + # Fetch initial data so we have data when entities subscribe try: await device_coordinator.async_config_entry_first_refresh() @@ -312,6 +337,8 @@ async def async_unload_entry( host.api.baichuan.unregister_callback(f"camera_{channel}_wake") if host.cancel_refresh_privacy_mode is not None: host.cancel_refresh_privacy_mode() + if host.cancel_first_firmware_check is not None: + host.cancel_first_firmware_check() return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 57af24043219c6..7b7cc48c1dddcb 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -130,6 +130,7 @@ def get_aiohttp_session() -> aiohttp.ClientSession: self._lost_subscription_start: bool = False self._lost_subscription: bool = False self.cancel_refresh_privacy_mode: CALLBACK_TYPE | None = None + self.cancel_first_firmware_check: CALLBACK_TYPE | None = None @callback def async_register_update_cmd(self, cmd: str, channel: int | None = None) -> None: From 19e76f604e232456b1b90f3dda26b6a816ced9ff Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 8 Dec 2025 21:26:48 +0000 Subject: [PATCH 2/4] Add tests --- tests/components/reolink/test_init.py | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 0e712542f8d96d..8300007f89fcca 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Callable +from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -146,6 +147,10 @@ async def test_firmware_error_twice( assert config_entry.state is ConfigEntryState.LOADED + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + entity_id = f"{Platform.UPDATE}.{TEST_NVR_NAME}_firmware" assert hass.states.get(entity_id).state == STATE_OFF @@ -1130,6 +1135,33 @@ def register_callback( assert hass.states.get(entity_id).state == STATE_OFF +@pytest.mark.parametrize(("last_check", "call_count"), [(25, 1), (23, 0)]) +async def test_firmware_update_delay( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + reolink_host: MagicMock, + config_entry: MockConfigEntry, + last_check: int, + call_count: int, +) -> None: + """Test delay of firmware update check.""" + reolink_host.baichuan_only = True + + store = MagicMock() + last_firmware_check = (datetime.now(UTC) - timedelta(hours=last_check)).isoformat() + store.async_load = AsyncMock(return_value=last_firmware_check) + + with patch("homeassistant.components.reolink.get_store", return_value=store): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert reolink_host.check_new_firmware.call_count == call_count + + async def test_baichaun_only( hass: HomeAssistant, reolink_host: MagicMock, From e350ce17865bbd25a7271aebe8527bfaf8d2989b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 13 Dec 2025 15:29:06 +0000 Subject: [PATCH 3/4] Use random update time instead --- homeassistant/components/reolink/__init__.py | 35 ++++++++------- homeassistant/components/reolink/const.py | 1 + tests/components/reolink/test_init.py | 45 +++++++++++++++----- 3 files changed, 54 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index b0d657ecd2e314..e6b85cb7e876db 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -6,6 +6,7 @@ from collections.abc import Callable from datetime import UTC, datetime, timedelta import logging +from random import uniform from time import time from typing import Any @@ -34,6 +35,7 @@ BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, CONF_BC_ONLY, CONF_BC_PORT, + CONF_FIRMWARE_CHECK_TIME, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN, @@ -138,9 +140,6 @@ async def async_setup_entry( } hass.config_entries.async_update_entry(config_entry, data=data) - # retrieve the firmware store to keep track of the last time the firmware was checked - firmware_store = get_store(hass, f"{config_entry.entry_id}_firmware") - min_timeout = host.api.timeout * (RETRY_ATTEMPTS + 2) update_timeout = max(min_timeout, min_timeout * host.api.num_cameras / 10) @@ -197,7 +196,6 @@ async def async_check_firmware_update() -> None: ) from err finally: host.starting = False - await firmware_store.async_save(datetime.now(UTC).isoformat()) device_coordinator = DataUpdateCoordinator( hass, @@ -225,19 +223,24 @@ async def first_firmware_check(*args: Any) -> None: await firmware_coordinator.async_refresh() host.cancel_first_firmware_check = None + # get update time from config entry + check_time_sec = config_entry.data.get(CONF_FIRMWARE_CHECK_TIME) + if check_time_sec is None: + check_time_sec = uniform(0, 86400) + data = { + **config_entry.data, + CONF_FIRMWARE_CHECK_TIME: check_time_sec, + } + hass.config_entries.async_update_entry(config_entry, data=data) + # If camera WAN blocked, firmware check fails and takes long, do not prevent setup - firmware_check_delay: int | timedelta = 5 - last_check = await firmware_store.async_load() - if last_check is not None: - firmware_check_delay = FIRMWARE_UPDATE_INTERVAL - ( - datetime.now(UTC) - datetime.fromisoformat(last_check) - ) - if ( - firmware_check_delay < timedelta(0) - or firmware_check_delay > FIRMWARE_UPDATE_INTERVAL - ): - firmware_check_delay = 5 - _LOGGER.debug( + now = datetime.now(UTC) + check_time = timedelta(seconds=check_time_sec) + delta_midnight = now - now.replace(hour=0, minute=0, second=0, microsecond=0) + firmware_check_delay = check_time - delta_midnight + if firmware_check_delay < timedelta(0): + firmware_check_delay += timedelta(days=1) + _LOGGER.error( "Scheduling first Reolink %s firmware check in %s", host.api.nvr_name, firmware_check_delay, diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index db2d105984be96..59d594a5406e05 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -6,6 +6,7 @@ CONF_BC_PORT = "baichuan_port" CONF_BC_ONLY = "baichuan_only" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" +CONF_FIRMWARE_CHECK_TIME = "firmware_check_time" # Conserve battery by not waking the battery cameras each minute during normal update # Most props are cached in the Home Hub and updated, but some are skipped diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 8300007f89fcca..593d51997fc9a6 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -23,6 +23,7 @@ BATTERY_ALL_WAKE_UPDATE_INTERVAL, BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, CONF_BC_PORT, + CONF_FIRMWARE_CHECK_TIME, DOMAIN, ) from homeassistant.config_entries import ConfigEntryState @@ -48,6 +49,7 @@ from homeassistant.setup import async_setup_component from .conftest import ( + CONF_BC_ONLY, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DEFAULT_PROTOCOL, @@ -59,6 +61,7 @@ TEST_MAC, TEST_MAC_CAM, TEST_NVR_NAME, + TEST_PASSWORD, TEST_PORT, TEST_PRIVACY, TEST_UID, @@ -147,14 +150,14 @@ async def test_firmware_error_twice( assert config_entry.state is ConfigEntryState.LOADED - freezer.tick(60) + freezer.tick(FIRMWARE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() entity_id = f"{Platform.UPDATE}.{TEST_NVR_NAME}_firmware" assert hass.states.get(entity_id).state == STATE_OFF - freezer.tick(FIRMWARE_UPDATE_INTERVAL) + freezer.tick(2 * FIRMWARE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -1135,24 +1138,44 @@ def register_callback( assert hass.states.get(entity_id).state == STATE_OFF -@pytest.mark.parametrize(("last_check", "call_count"), [(25, 1), (23, 0)]) +@pytest.mark.parametrize(("seconds", "call_count"), [(10, 1), (3600, 0)]) async def test_firmware_update_delay( hass: HomeAssistant, freezer: FrozenDateTimeFactory, reolink_host: MagicMock, - config_entry: MockConfigEntry, - last_check: int, + seconds: int, call_count: int, ) -> None: """Test delay of firmware update check.""" - reolink_host.baichuan_only = True + now = datetime.now(UTC) + check_delay = ( + now + + timedelta(seconds=seconds) + - now.replace(hour=0, minute=0, second=0, microsecond=0) + ).total_seconds() - store = MagicMock() - last_firmware_check = (datetime.now(UTC) - timedelta(hours=last_check)).isoformat() - store.async_load = AsyncMock(return_value=last_firmware_check) + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, + CONF_FIRMWARE_CHECK_TIME: check_delay, + }, + options={ + CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) - with patch("homeassistant.components.reolink.get_store", return_value=store): - assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() freezer.tick(60) From d7a3ea5ed40b433a33ba621dbbb8514d3a541f39 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 13 Dec 2025 15:32:19 +0000 Subject: [PATCH 4/4] fix log level --- homeassistant/components/reolink/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index e6b85cb7e876db..5fbe1ba39512e1 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -240,7 +240,7 @@ async def first_firmware_check(*args: Any) -> None: firmware_check_delay = check_time - delta_midnight if firmware_check_delay < timedelta(0): firmware_check_delay += timedelta(days=1) - _LOGGER.error( + _LOGGER.debug( "Scheduling first Reolink %s firmware check in %s", host.api.nvr_name, firmware_check_delay,