Cleanup Reolink firmware update entity (#119239)

This commit is contained in:
starkillerOG 2024-06-13 21:27:30 +02:00 committed by GitHub
parent b4a77f8341
commit b8851f2f3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 94 additions and 79 deletions

View File

@ -6,11 +6,9 @@ import asyncio
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Literal
from reolink_aio.api import RETRY_ATTEMPTS
from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError
from reolink_aio.software_version import NewSoftwareVersion
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
@ -47,9 +45,7 @@ class ReolinkData:
host: ReolinkHost
device_coordinator: DataUpdateCoordinator[None]
firmware_coordinator: DataUpdateCoordinator[
str | Literal[False] | NewSoftwareVersion
]
firmware_coordinator: DataUpdateCoordinator[None]
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
@ -93,16 +89,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
await host.renew()
async def async_check_firmware_update() -> (
str | Literal[False] | NewSoftwareVersion
):
async def async_check_firmware_update() -> None:
"""Check for firmware updates."""
if not host.api.supported(None, "update"):
return False
async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
try:
return await host.api.check_new_firmware()
await host.api.check_new_firmware()
except ReolinkError as err:
if starting:
_LOGGER.debug(
@ -110,7 +101,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
"from %s, possibly internet access is blocked",
host.api.nvr_name,
)
return False
return
raise UpdateFailed(
f"Error checking Reolink firmware update from {host.api.nvr_name}, "
@ -151,13 +142,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
)
cleanup_disconnected_cams(hass, config_entry.entry_id, host)
# Can be remove in HA 2024.6.0
entity_reg = er.async_get(hass)
entities = er.async_entries_for_config_entry(entity_reg, config_entry.entry_id)
for entity in entities:
if entity.domain == "light" and entity.unique_id.endswith("ir_lights"):
entity_reg.async_remove(entity.entity_id)
migrate_entity_ids(hass, config_entry.entry_id, host)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
@ -234,3 +219,17 @@ def cleanup_disconnected_cams(
# clean device registry and associated entities
device_reg.async_remove_device(device.id)
def migrate_entity_ids(
hass: HomeAssistant, config_entry_id: str, host: ReolinkHost
) -> None:
"""Migrate entity IDs if needed."""
entity_reg = er.async_get(hass)
entities = er.async_entries_for_config_entry(entity_reg, config_entry_id)
for entity in entities:
# Can be remove in HA 2025.1.0
if entity.domain == "update" and entity.unique_id == host.unique_id:
entity_reg.async_update_entity(
entity.entity_id, new_unique_id=f"{host.unique_id}_firmware"
)

View File

@ -34,22 +34,28 @@ class ReolinkHostEntityDescription(EntityDescription):
supported: Callable[[Host], bool] = lambda api: True
class ReolinkBaseCoordinatorEntity[_DataT](
CoordinatorEntity[DataUpdateCoordinator[_DataT]]
):
"""Parent class for Reolink entities."""
class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]]):
"""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
basically a NVR with a single channel that has the camera connected to that channel.
"""
_attr_has_entity_name = True
entity_description: ReolinkHostEntityDescription | ReolinkChannelEntityDescription
def __init__(
self,
reolink_data: ReolinkData,
coordinator: DataUpdateCoordinator[_DataT],
coordinator: DataUpdateCoordinator[None] | None = None,
) -> None:
"""Initialize ReolinkBaseCoordinatorEntity."""
"""Initialize ReolinkHostCoordinatorEntity."""
if coordinator is None:
coordinator = reolink_data.device_coordinator
super().__init__(coordinator)
self._host = reolink_data.host
self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}"
http_s = "https" if self._host.api.use_https else "http"
self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}"
@ -70,22 +76,6 @@ class ReolinkBaseCoordinatorEntity[_DataT](
"""Return True if entity is available."""
return self._host.api.session_active and super().available
class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]):
"""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
basically a NVR with a single channel that has the camera connected to that channel.
"""
entity_description: ReolinkHostEntityDescription | ReolinkChannelEntityDescription
def __init__(self, reolink_data: ReolinkData) -> None:
"""Initialize ReolinkHostCoordinatorEntity."""
super().__init__(reolink_data, reolink_data.device_coordinator)
self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}"
async def async_added_to_hass(self) -> None:
"""Entity created."""
await super().async_added_to_hass()
@ -116,9 +106,10 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
self,
reolink_data: ReolinkData,
channel: int,
coordinator: DataUpdateCoordinator[None] | None = None,
) -> None:
"""Initialize ReolinkChannelCoordinatorEntity for a hardware camera connected to a channel of the NVR."""
super().__init__(reolink_data)
super().__init__(reolink_data, coordinator)
self._channel = channel
self._attr_unique_id = (

View File

@ -2,9 +2,9 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
import logging
from typing import Any, Literal
from typing import Any
from reolink_aio.exceptions import ReolinkError
from reolink_aio.software_version import NewSoftwareVersion
@ -12,6 +12,7 @@ from reolink_aio.software_version import NewSoftwareVersion
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
@ -22,13 +23,28 @@ from homeassistant.helpers.event import async_call_later
from . import ReolinkData
from .const import DOMAIN
from .entity import ReolinkBaseCoordinatorEntity
LOGGER = logging.getLogger(__name__)
from .entity import ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription
POLL_AFTER_INSTALL = 120
@dataclass(frozen=True, kw_only=True)
class ReolinkHostUpdateEntityDescription(
UpdateEntityDescription,
ReolinkHostEntityDescription,
):
"""A class that describes host update entities."""
HOST_UPDATE_ENTITIES = (
ReolinkHostUpdateEntityDescription(
key="firmware",
supported=lambda api: api.supported(None, "firmware"),
device_class=UpdateDeviceClass.FIRMWARE,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@ -36,26 +52,32 @@ async def async_setup_entry(
) -> None:
"""Set up update entities for Reolink component."""
reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities([ReolinkUpdateEntity(reolink_data)])
entities: list[ReolinkHostUpdateEntity] = [
ReolinkHostUpdateEntity(reolink_data, entity_description)
for entity_description in HOST_UPDATE_ENTITIES
if entity_description.supported(reolink_data.host.api)
]
async_add_entities(entities)
class ReolinkUpdateEntity(
ReolinkBaseCoordinatorEntity[str | Literal[False] | NewSoftwareVersion],
class ReolinkHostUpdateEntity(
ReolinkHostCoordinatorEntity,
UpdateEntity,
):
"""Update entity for a Netgear device."""
"""Update entity class for Reolink Host."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
entity_description: ReolinkHostUpdateEntityDescription
_attr_release_url = "https://reolink.com/download-center/"
def __init__(
self,
reolink_data: ReolinkData,
entity_description: ReolinkHostUpdateEntityDescription,
) -> None:
"""Initialize a Netgear device."""
"""Initialize Reolink update entity."""
self.entity_description = entity_description
super().__init__(reolink_data, reolink_data.firmware_coordinator)
self._attr_unique_id = f"{self._host.unique_id}"
self._cancel_update: CALLBACK_TYPE | None = None
@property
@ -66,32 +88,35 @@ class ReolinkUpdateEntity(
@property
def latest_version(self) -> str | None:
"""Latest version available for install."""
if not self.coordinator.data:
new_firmware = self._host.api.firmware_update_available()
if not new_firmware:
return self.installed_version
if isinstance(self.coordinator.data, str):
return self.coordinator.data
if isinstance(new_firmware, str):
return new_firmware
return self.coordinator.data.version_string
return new_firmware.version_string
@property
def supported_features(self) -> UpdateEntityFeature:
"""Flag supported features."""
supported_features = UpdateEntityFeature.INSTALL
if isinstance(self.coordinator.data, NewSoftwareVersion):
new_firmware = self._host.api.firmware_update_available()
if isinstance(new_firmware, NewSoftwareVersion):
supported_features |= UpdateEntityFeature.RELEASE_NOTES
return supported_features
async def async_release_notes(self) -> str | None:
"""Return the release notes."""
if not isinstance(self.coordinator.data, NewSoftwareVersion):
new_firmware = self._host.api.firmware_update_available()
if not isinstance(new_firmware, NewSoftwareVersion):
return None
return (
"If the install button fails, download this"
f" [firmware zip file]({self.coordinator.data.download_url})."
f" [firmware zip file]({new_firmware.download_url})."
" Then, follow the installation guide (PDF in the zip file).\n\n"
f"## Release notes\n\n{self.coordinator.data.release_notes}"
f"## Release notes\n\n{new_firmware.release_notes}"
)
async def async_install(

View File

@ -86,6 +86,7 @@ def reolink_connect_class() -> Generator[MagicMock]:
host_mock.camera_name.return_value = TEST_NVR_NAME
host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000"
host_mock.camera_uid.return_value = TEST_UID
host_mock.firmware_update_available.return_value = False
host_mock.session_active = True
host_mock.timeout = 60
host_mock.renewtimer.return_value = 600

View File

@ -178,40 +178,39 @@ async def test_cleanup_disconnected_cams(
assert sorted(device_models) == sorted(expected_models)
async def test_cleanup_deprecated_entities(
async def test_migrate_entity_ids(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test deprecated ir_lights light entity is cleaned."""
"""Test entity ids that need to be migrated."""
reolink_connect.channels = [0]
ir_id = f"{TEST_MAC}_0_ir_lights"
original_id = f"{TEST_MAC}"
new_id = f"{TEST_MAC}_firmware"
domain = Platform.UPDATE
entity_registry.async_get_or_create(
domain=Platform.LIGHT,
domain=domain,
platform=const.DOMAIN,
unique_id=ir_id,
unique_id=original_id,
config_entry=config_entry,
suggested_object_id=ir_id,
suggested_object_id=original_id,
disabled_by=None,
)
assert entity_registry.async_get_entity_id(Platform.LIGHT, const.DOMAIN, ir_id)
assert (
entity_registry.async_get_entity_id(Platform.SWITCH, const.DOMAIN, ir_id)
is None
)
assert entity_registry.async_get_entity_id(domain, const.DOMAIN, original_id)
assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id) is None
# setup CH 0 and NVR switch entities/device
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]):
# setup CH 0 and host entities/device
with patch("homeassistant.components.reolink.PLATFORMS", [domain]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert (
entity_registry.async_get_entity_id(Platform.LIGHT, const.DOMAIN, ir_id) is None
entity_registry.async_get_entity_id(domain, const.DOMAIN, original_id) is None
)
assert entity_registry.async_get_entity_id(Platform.SWITCH, const.DOMAIN, ir_id)
assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id)
async def test_no_repair_issue(