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 dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Literal
from reolink_aio.api import RETRY_ATTEMPTS from reolink_aio.api import RETRY_ATTEMPTS
from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError
from reolink_aio.software_version import NewSoftwareVersion
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
@ -47,9 +45,7 @@ class ReolinkData:
host: ReolinkHost host: ReolinkHost
device_coordinator: DataUpdateCoordinator[None] device_coordinator: DataUpdateCoordinator[None]
firmware_coordinator: DataUpdateCoordinator[ firmware_coordinator: DataUpdateCoordinator[None]
str | Literal[False] | NewSoftwareVersion
]
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 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)): async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
await host.renew() await host.renew()
async def async_check_firmware_update() -> ( async def async_check_firmware_update() -> None:
str | Literal[False] | NewSoftwareVersion
):
"""Check for firmware updates.""" """Check for firmware updates."""
if not host.api.supported(None, "update"):
return False
async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
try: try:
return await host.api.check_new_firmware() await host.api.check_new_firmware()
except ReolinkError as err: except ReolinkError as err:
if starting: if starting:
_LOGGER.debug( _LOGGER.debug(
@ -110,7 +101,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
"from %s, possibly internet access is blocked", "from %s, possibly internet access is blocked",
host.api.nvr_name, host.api.nvr_name,
) )
return False return
raise UpdateFailed( raise UpdateFailed(
f"Error checking Reolink firmware update from {host.api.nvr_name}, " 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) cleanup_disconnected_cams(hass, config_entry.entry_id, host)
migrate_entity_ids(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)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 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 # clean device registry and associated entities
device_reg.async_remove_device(device.id) 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 supported: Callable[[Host], bool] = lambda api: True
class ReolinkBaseCoordinatorEntity[_DataT]( class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]]):
CoordinatorEntity[DataUpdateCoordinator[_DataT]] """Parent class for entities that control the Reolink NVR itself, without a channel.
):
"""Parent class for Reolink entities.""" 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 _attr_has_entity_name = True
entity_description: ReolinkHostEntityDescription | ReolinkChannelEntityDescription
def __init__( def __init__(
self, self,
reolink_data: ReolinkData, reolink_data: ReolinkData,
coordinator: DataUpdateCoordinator[_DataT], coordinator: DataUpdateCoordinator[None] | None = None,
) -> None: ) -> None:
"""Initialize ReolinkBaseCoordinatorEntity.""" """Initialize ReolinkHostCoordinatorEntity."""
if coordinator is None:
coordinator = reolink_data.device_coordinator
super().__init__(coordinator) super().__init__(coordinator)
self._host = reolink_data.host 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" 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}" 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 True if entity is available."""
return self._host.api.session_active and super().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: async def async_added_to_hass(self) -> None:
"""Entity created.""" """Entity created."""
await super().async_added_to_hass() await super().async_added_to_hass()
@ -116,9 +106,10 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
self, self,
reolink_data: ReolinkData, reolink_data: ReolinkData,
channel: int, channel: int,
coordinator: DataUpdateCoordinator[None] | None = None,
) -> None: ) -> None:
"""Initialize ReolinkChannelCoordinatorEntity for a hardware camera connected to a channel of the NVR.""" """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._channel = channel
self._attr_unique_id = ( self._attr_unique_id = (

View File

@ -2,9 +2,9 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
import logging from typing import Any
from typing import Any, Literal
from reolink_aio.exceptions import ReolinkError from reolink_aio.exceptions import ReolinkError
from reolink_aio.software_version import NewSoftwareVersion from reolink_aio.software_version import NewSoftwareVersion
@ -12,6 +12,7 @@ from reolink_aio.software_version import NewSoftwareVersion
from homeassistant.components.update import ( from homeassistant.components.update import (
UpdateDeviceClass, UpdateDeviceClass,
UpdateEntity, UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature, UpdateEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -22,13 +23,28 @@ from homeassistant.helpers.event import async_call_later
from . import ReolinkData from . import ReolinkData
from .const import DOMAIN from .const import DOMAIN
from .entity import ReolinkBaseCoordinatorEntity from .entity import ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription
LOGGER = logging.getLogger(__name__)
POLL_AFTER_INSTALL = 120 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
@ -36,26 +52,32 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up update entities for Reolink component.""" """Set up update entities for Reolink component."""
reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] 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( class ReolinkHostUpdateEntity(
ReolinkBaseCoordinatorEntity[str | Literal[False] | NewSoftwareVersion], ReolinkHostCoordinatorEntity,
UpdateEntity, 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/" _attr_release_url = "https://reolink.com/download-center/"
def __init__( def __init__(
self, self,
reolink_data: ReolinkData, reolink_data: ReolinkData,
entity_description: ReolinkHostUpdateEntityDescription,
) -> None: ) -> None:
"""Initialize a Netgear device.""" """Initialize Reolink update entity."""
self.entity_description = entity_description
super().__init__(reolink_data, reolink_data.firmware_coordinator) super().__init__(reolink_data, reolink_data.firmware_coordinator)
self._attr_unique_id = f"{self._host.unique_id}"
self._cancel_update: CALLBACK_TYPE | None = None self._cancel_update: CALLBACK_TYPE | None = None
@property @property
@ -66,32 +88,35 @@ class ReolinkUpdateEntity(
@property @property
def latest_version(self) -> str | None: def latest_version(self) -> str | None:
"""Latest version available for install.""" """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 return self.installed_version
if isinstance(self.coordinator.data, str): if isinstance(new_firmware, str):
return self.coordinator.data return new_firmware
return self.coordinator.data.version_string return new_firmware.version_string
@property @property
def supported_features(self) -> UpdateEntityFeature: def supported_features(self) -> UpdateEntityFeature:
"""Flag supported features.""" """Flag supported features."""
supported_features = UpdateEntityFeature.INSTALL 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 supported_features |= UpdateEntityFeature.RELEASE_NOTES
return supported_features return supported_features
async def async_release_notes(self) -> str | None: async def async_release_notes(self) -> str | None:
"""Return the release notes.""" """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 None
return ( return (
"If the install button fails, download this" "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" " 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( 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_name.return_value = TEST_NVR_NAME
host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000"
host_mock.camera_uid.return_value = TEST_UID host_mock.camera_uid.return_value = TEST_UID
host_mock.firmware_update_available.return_value = False
host_mock.session_active = True host_mock.session_active = True
host_mock.timeout = 60 host_mock.timeout = 60
host_mock.renewtimer.return_value = 600 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) assert sorted(device_models) == sorted(expected_models)
async def test_cleanup_deprecated_entities( async def test_migrate_entity_ids(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
reolink_connect: MagicMock, reolink_connect: MagicMock,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Test deprecated ir_lights light entity is cleaned.""" """Test entity ids that need to be migrated."""
reolink_connect.channels = [0] 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( entity_registry.async_get_or_create(
domain=Platform.LIGHT, domain=domain,
platform=const.DOMAIN, platform=const.DOMAIN,
unique_id=ir_id, unique_id=original_id,
config_entry=config_entry, config_entry=config_entry,
suggested_object_id=ir_id, suggested_object_id=original_id,
disabled_by=None, disabled_by=None,
) )
assert entity_registry.async_get_entity_id(Platform.LIGHT, const.DOMAIN, ir_id) assert entity_registry.async_get_entity_id(domain, const.DOMAIN, original_id)
assert ( assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id) is None
entity_registry.async_get_entity_id(Platform.SWITCH, const.DOMAIN, ir_id)
is None
)
# setup CH 0 and NVR switch entities/device # setup CH 0 and host entities/device
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): with patch("homeassistant.components.reolink.PLATFORMS", [domain]):
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() await hass.async_block_till_done()
assert ( 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( async def test_no_repair_issue(