mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 09:17:53 +00:00
Cleanup Reolink firmware update entity (#119239)
This commit is contained in:
parent
b4a77f8341
commit
b8851f2f3c
@ -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"
|
||||
)
|
||||
|
@ -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 = (
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user