1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 08:26:41 +01:00

Move DataUpdateCoordinator to separate module in reolink (#164914)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
epenet
2026-03-06 19:59:56 +01:00
committed by GitHub
parent f57884cb95
commit 0d04d79844
11 changed files with 220 additions and 136 deletions

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import logging
@@ -11,13 +10,8 @@ from time import time
from typing import Any
from reolink_aio.api import RETRY_ATTEMPTS
from reolink_aio.exceptions import (
CredentialsInvalidError,
LoginPrivacyModeError,
ReolinkError,
)
from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
@@ -29,7 +23,6 @@ from homeassistant.helpers import (
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
@@ -40,6 +33,7 @@ from .const import (
CONF_USE_HTTPS,
DOMAIN,
)
from .coordinator import ReolinkDeviceCoordinator, ReolinkFirmwareCoordinator
from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin
from .host import ReolinkHost
from .services import async_setup_services
@@ -60,10 +54,7 @@ PLATFORMS = [
Platform.SWITCH,
Platform.UPDATE,
]
DEVICE_UPDATE_INTERVAL_MIN = timedelta(seconds=60)
DEVICE_UPDATE_INTERVAL_PER_CAM = timedelta(seconds=10)
FIRMWARE_UPDATE_INTERVAL = timedelta(hours=24)
NUM_CRED_ERRORS = 3
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@@ -141,103 +132,21 @@ async def async_setup_entry(
hass.config_entries.async_update_entry(config_entry, data=data)
min_timeout = host.api.timeout * (RETRY_ATTEMPTS + 2)
update_timeout = max(min_timeout, min_timeout * host.api.num_cameras / 10)
# Track firmware versions to detect external updates (e.g., via Reolink app)
last_known_firmware: dict[int | None, str | None] = {}
async def async_device_config_update() -> None:
"""Update the host state cache and renew the ONVIF-subscription."""
nonlocal last_known_firmware
async with asyncio.timeout(update_timeout):
try:
await host.update_states()
except CredentialsInvalidError as err:
host.credential_errors += 1
if host.credential_errors >= NUM_CRED_ERRORS:
await host.stop()
raise ConfigEntryAuthFailed(err) from err
raise UpdateFailed(str(err)) from err
except LoginPrivacyModeError:
pass # HTTP API is shutdown when privacy mode is active
except ReolinkError as err:
host.credential_errors = 0
raise UpdateFailed(str(err)) from err
host.credential_errors = 0
# Check for firmware version changes (external update detection)
firmware_changed = False
for ch in (*host.api.channels, None):
new_version = host.api.camera_sw_version(ch)
old_version = last_known_firmware.get(ch)
if (
old_version is not None
and new_version is not None
and new_version != old_version
):
firmware_changed = True
last_known_firmware[ch] = new_version
# Notify firmware coordinator if firmware changed externally
if firmware_changed and firmware_coordinator is not None:
firmware_coordinator.async_set_updated_data(None)
async with asyncio.timeout(min_timeout):
await host.renew()
if host.api.new_devices and config_entry.state == ConfigEntryState.LOADED:
# Their are new cameras/chimes connected, reload to add them.
_LOGGER.debug(
"Reloading Reolink %s to add new device (capabilities)",
host.api.nvr_name,
)
hass.async_create_task(
hass.config_entries.async_reload(config_entry.entry_id)
)
async def async_check_firmware_update() -> None:
"""Check for firmware updates."""
async with asyncio.timeout(min_timeout):
try:
await host.api.check_new_firmware(host.firmware_ch_list)
except ReolinkError as err:
if host.starting:
_LOGGER.debug(
"Error checking Reolink firmware update at startup "
"from %s, possibly internet access is blocked",
host.api.nvr_name,
)
return
raise UpdateFailed(
f"Error checking Reolink firmware update from {host.api.nvr_name}, "
"if the camera is blocked from accessing the internet, "
"disable the update entity"
) from err
finally:
host.starting = False
device_coordinator = DataUpdateCoordinator(
device_coordinator = ReolinkDeviceCoordinator(
hass,
_LOGGER,
config_entry=config_entry,
name=f"reolink.{host.api.nvr_name}",
update_method=async_device_config_update,
update_interval=max(
DEVICE_UPDATE_INTERVAL_MIN,
DEVICE_UPDATE_INTERVAL_PER_CAM * host.api.num_cameras,
),
config_entry,
host,
min_timeout=min_timeout,
)
firmware_coordinator = DataUpdateCoordinator(
firmware_coordinator = ReolinkFirmwareCoordinator(
hass,
_LOGGER,
config_entry=config_entry,
name=f"reolink.{host.api.nvr_name}.firmware",
update_method=async_check_firmware_update,
update_interval=None, # Do not fetch data automatically, resume 24h schedule
config_entry,
host,
min_timeout=min_timeout,
)
device_coordinator.firmware_coordinator = firmware_coordinator
async def first_firmware_check(*args: Any) -> None:
"""Start first firmware check delayed to continue 24h schedule."""
@@ -305,7 +214,7 @@ async def async_setup_entry(
async def register_callbacks(
host: ReolinkHost,
device_coordinator: DataUpdateCoordinator[None],
device_coordinator: ReolinkDeviceCoordinator,
hass: HomeAssistant,
) -> None:
"""Register update callbacks."""

View File

@@ -0,0 +1,178 @@
"""Data update coordinators for Reolink."""
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from reolink_aio.exceptions import (
CredentialsInvalidError,
LoginPrivacyModeError,
ReolinkError,
)
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .host import ReolinkHost
_LOGGER = logging.getLogger(__name__)
NUM_CRED_ERRORS = 3
DEVICE_UPDATE_INTERVAL_MIN = timedelta(seconds=60)
DEVICE_UPDATE_INTERVAL_PER_CAM = timedelta(seconds=10)
class ReolinkCoordinator(DataUpdateCoordinator[None]):
"""Coordinator for Reolink."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
host: ReolinkHost,
name: str,
*,
min_timeout: float,
update_interval: timedelta | None,
) -> None:
"""Initialize the device coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=name,
update_interval=update_interval,
)
self._host = host
self._min_timeout = min_timeout
class ReolinkDeviceCoordinator(ReolinkCoordinator):
"""Coordinator for Reolink device state updates."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
host: ReolinkHost,
*,
min_timeout: float,
) -> None:
"""Initialize the device coordinator."""
super().__init__(
hass,
config_entry,
host,
f"reolink.{host.api.nvr_name}",
min_timeout=min_timeout,
update_interval=max(
DEVICE_UPDATE_INTERVAL_MIN,
DEVICE_UPDATE_INTERVAL_PER_CAM * host.api.num_cameras,
),
)
self._update_timeout = max(min_timeout, min_timeout * host.api.num_cameras / 10)
self._last_known_firmware: dict[int | None, str | None] = {}
self.firmware_coordinator: ReolinkFirmwareCoordinator | None = None
async def _async_update_data(self) -> None:
"""Update the host state cache and renew the ONVIF-subscription."""
async with asyncio.timeout(self._update_timeout):
try:
await self._host.update_states()
except CredentialsInvalidError as err:
self._host.credential_errors += 1
if self._host.credential_errors >= NUM_CRED_ERRORS:
await self._host.stop()
raise ConfigEntryAuthFailed(err) from err
raise UpdateFailed(str(err)) from err
except LoginPrivacyModeError:
pass # HTTP API is shutdown when privacy mode is active
except ReolinkError as err:
self._host.credential_errors = 0
raise UpdateFailed(str(err)) from err
self._host.credential_errors = 0
# Check for firmware version changes (external update detection)
firmware_changed = False
for ch in (*self._host.api.channels, None):
new_version = self._host.api.camera_sw_version(ch)
old_version = self._last_known_firmware.get(ch)
if (
old_version is not None
and new_version is not None
and new_version != old_version
):
firmware_changed = True
self._last_known_firmware[ch] = new_version
# Notify firmware coordinator if firmware changed externally
if firmware_changed and self.firmware_coordinator is not None:
self.firmware_coordinator.async_set_updated_data(None)
async with asyncio.timeout(self._min_timeout):
await self._host.renew()
if (
self._host.api.new_devices
and self.config_entry.state == ConfigEntryState.LOADED
):
# There are new cameras/chimes connected, reload to add them.
_LOGGER.debug(
"Reloading Reolink %s to add new device (capabilities)",
self._host.api.nvr_name,
)
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.config_entry.entry_id)
)
class ReolinkFirmwareCoordinator(ReolinkCoordinator):
"""Coordinator for Reolink firmware update checks."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
host: ReolinkHost,
*,
min_timeout: float,
) -> None:
"""Initialize the firmware coordinator."""
super().__init__(
hass,
config_entry,
host,
f"reolink.{host.api.nvr_name}.firmware",
min_timeout=min_timeout,
update_interval=None, # Do not fetch data automatically, resume 24h schedule
)
async def _async_update_data(self) -> None:
"""Check for firmware updates."""
async with asyncio.timeout(self._min_timeout):
try:
await self._host.api.check_new_firmware(self._host.firmware_ch_list)
except ReolinkError as err:
if self._host.starting:
_LOGGER.debug(
"Error checking Reolink firmware update at startup "
"from %s, possibly internet access is blocked",
self._host.api.nvr_name,
)
return
raise UpdateFailed(
f"Error checking Reolink firmware update from {self._host.api.nvr_name}, "
"if the camera is blocked from accessing the internet, "
"disable the update entity"
) from err
finally:
self._host.starting = False

View File

@@ -10,13 +10,11 @@ from reolink_aio.api import DUAL_LENS_MODELS, Chime, Host
from homeassistant.core import callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import ReolinkData
from .const import DOMAIN
from .coordinator import ReolinkCoordinator
from .util import ReolinkData
@dataclass(frozen=True, kw_only=True)
@@ -49,7 +47,7 @@ class ReolinkChimeEntityDescription(ReolinkEntityDescription):
supported: Callable[[Chime], bool] = lambda chime: True
class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]]):
class ReolinkHostCoordinatorEntity(CoordinatorEntity[ReolinkCoordinator]):
"""Parent class for entities that control the Reolink NVR itself, without a channel.
A camera connected directly to HomeAssistant without using a NVR is in the reolink API
@@ -62,7 +60,7 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
def __init__(
self,
reolink_data: ReolinkData,
coordinator: DataUpdateCoordinator[None] | None = None,
coordinator: ReolinkCoordinator | None = None,
) -> None:
"""Initialize ReolinkHostCoordinatorEntity."""
if coordinator is None:
@@ -161,7 +159,7 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
self,
reolink_data: ReolinkData,
channel: int,
coordinator: DataUpdateCoordinator[None] | None = None,
coordinator: ReolinkCoordinator | None = None,
) -> None:
"""Initialize ReolinkChannelCoordinatorEntity for a hardware camera connected to a channel of the NVR."""
super().__init__(reolink_data, coordinator)
@@ -250,7 +248,7 @@ class ReolinkHostChimeCoordinatorEntity(ReolinkHostCoordinatorEntity):
self,
reolink_data: ReolinkData,
chime: Chime,
coordinator: DataUpdateCoordinator[None] | None = None,
coordinator: ReolinkCoordinator | None = None,
) -> None:
"""Initialize ReolinkHostChimeCoordinatorEntity for a chime."""
super().__init__(reolink_data, coordinator)
@@ -287,7 +285,7 @@ class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity):
self,
reolink_data: ReolinkData,
chime: Chime,
coordinator: DataUpdateCoordinator[None] | None = None,
coordinator: ReolinkCoordinator | None = None,
) -> None:
"""Initialize ReolinkChimeCoordinatorEntity for a chime."""
assert chime.channel is not None

View File

@@ -18,13 +18,14 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import DEVICE_UPDATE_INTERVAL_MIN, DEVICE_UPDATE_INTERVAL_PER_CAM
from .const import DOMAIN
from .coordinator import (
DEVICE_UPDATE_INTERVAL_MIN,
DEVICE_UPDATE_INTERVAL_PER_CAM,
ReolinkCoordinator,
)
from .entity import (
ReolinkChannelCoordinatorEntity,
ReolinkChannelEntityDescription,
@@ -94,9 +95,7 @@ async def async_setup_entry(
async_add_entities(entities)
class ReolinkUpdateBaseEntity(
CoordinatorEntity[DataUpdateCoordinator[None]], UpdateEntity
):
class ReolinkUpdateBaseEntity(CoordinatorEntity[ReolinkCoordinator], UpdateEntity):
"""Base update entity class for Reolink."""
_attr_release_url = "https://reolink.com/download-center/"
@@ -105,7 +104,7 @@ class ReolinkUpdateBaseEntity(
self,
reolink_data: ReolinkData,
channel: int | None,
coordinator: DataUpdateCoordinator[None],
coordinator: ReolinkCoordinator,
) -> None:
"""Initialize Reolink update entity."""
CoordinatorEntity.__init__(self, coordinator)

View File

@@ -28,11 +28,11 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.storage import Store
from homeassistant.helpers.translation import async_get_exception_message
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
if TYPE_CHECKING:
from .coordinator import ReolinkDeviceCoordinator, ReolinkFirmwareCoordinator
from .host import ReolinkHost
STORAGE_VERSION = 1
@@ -45,8 +45,8 @@ class ReolinkData:
"""Data for the Reolink integration."""
host: ReolinkHost
device_coordinator: DataUpdateCoordinator[None]
firmware_coordinator: DataUpdateCoordinator[None]
device_coordinator: ReolinkDeviceCoordinator
firmware_coordinator: ReolinkFirmwareCoordinator
def is_connected(hass: HomeAssistant, config_entry: config_entries.ConfigEntry) -> bool:

View File

@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL_MIN
from homeassistant.components.reolink.coordinator import DEVICE_UPDATE_INTERVAL_MIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant

View File

@@ -16,7 +16,6 @@ from reolink_aio.exceptions import (
)
from homeassistant import config_entries
from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL_MIN
from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL
from homeassistant.components.reolink.const import (
CONF_BC_ONLY,
@@ -25,6 +24,7 @@ from homeassistant.components.reolink.const import (
CONF_USE_HTTPS,
DOMAIN,
)
from homeassistant.components.reolink.coordinator import DEVICE_UPDATE_INTERVAL_MIN
from homeassistant.components.reolink.exceptions import ReolinkWebhookException
from homeassistant.components.reolink.host import DEFAULT_TIMEOUT
from homeassistant.config_entries import ConfigEntryState

View File

@@ -10,7 +10,7 @@ import pytest
from reolink_aio.enums import SubType
from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError
from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL_MIN
from homeassistant.components.reolink.coordinator import DEVICE_UPDATE_INTERVAL_MIN
from homeassistant.components.reolink.host import (
FIRST_ONVIF_LONG_POLL_TIMEOUT,
FIRST_ONVIF_TIMEOUT,

View File

@@ -14,11 +14,7 @@ from reolink_aio.exceptions import (
ReolinkError,
)
from homeassistant.components.reolink import (
DEVICE_UPDATE_INTERVAL_MIN,
FIRMWARE_UPDATE_INTERVAL,
NUM_CRED_ERRORS,
)
from homeassistant.components.reolink import FIRMWARE_UPDATE_INTERVAL
from homeassistant.components.reolink.const import (
BATTERY_ALL_WAKE_UPDATE_INTERVAL,
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
@@ -26,6 +22,10 @@ from homeassistant.components.reolink.const import (
CONF_FIRMWARE_CHECK_TIME,
DOMAIN,
)
from homeassistant.components.reolink.coordinator import (
DEVICE_UPDATE_INTERVAL_MIN,
NUM_CRED_ERRORS,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
CONF_HOST,

View File

@@ -7,7 +7,7 @@ import pytest
from reolink_aio.api import Chime
from reolink_aio.exceptions import InvalidParameterError, ReolinkError
from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL_MIN
from homeassistant.components.reolink.coordinator import DEVICE_UPDATE_INTERVAL_MIN
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (

View File

@@ -7,7 +7,7 @@ import pytest
from reolink_aio.api import Chime
from reolink_aio.exceptions import ReolinkError
from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL_MIN
from homeassistant.components.reolink.coordinator import DEVICE_UPDATE_INTERVAL_MIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (