diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 9807739b790..64058caba78 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -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" + ) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 309e5b54fe0..f722944a2fc 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -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 = ( diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 41933ae2efc..2adbd225cef 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -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( diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index d997b57bb52..9b7dd481c9d 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -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 diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 40b12b65f43..3cca1831a28 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -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(