diff --git a/.coveragerc b/.coveragerc index 871f798a653..7429966011a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -972,6 +972,7 @@ omit = homeassistant/components/reolink/entity.py homeassistant/components/reolink/host.py homeassistant/components/reolink/number.py + homeassistant/components/reolink/update.py homeassistant/components/repetier/__init__.py homeassistant/components/repetier/sensor.py homeassistant/components/rest/notify.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 0ff0861f65f..2d3fc52eb30 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -23,8 +23,9 @@ from .host import ReolinkHost _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.NUMBER] -DEVICE_UPDATE_INTERVAL = 60 +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.NUMBER, Platform.UPDATE] +DEVICE_UPDATE_INTERVAL = timedelta(seconds=60) +FIRMWARE_UPDATE_INTERVAL = timedelta(hours=12) @dataclass @@ -33,6 +34,7 @@ class ReolinkData: host: ReolinkHost device_coordinator: DataUpdateCoordinator + firmware_coordinator: DataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -75,23 +77,44 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async with async_timeout.timeout(host.api.timeout): await host.renew() - coordinator_device_config_update = DataUpdateCoordinator( + async def async_check_firmware_update(): + """Check for firmware updates.""" + async with async_timeout.timeout(host.api.timeout): + try: + return await host.api.check_new_firmware() + except ReolinkError as err: + raise UpdateFailed( + f"Error checking Reolink firmware update {host.api.nvr_name}" + ) from err + + device_coordinator = DataUpdateCoordinator( hass, _LOGGER, name=f"reolink.{host.api.nvr_name}", update_method=async_device_config_update, - update_interval=timedelta(seconds=DEVICE_UPDATE_INTERVAL), + update_interval=DEVICE_UPDATE_INTERVAL, + ) + firmware_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"reolink.{host.api.nvr_name}.firmware", + update_method=async_check_firmware_update, + update_interval=FIRMWARE_UPDATE_INTERVAL, ) # Fetch initial data so we have data when entities subscribe try: - await coordinator_device_config_update.async_config_entry_first_refresh() + await asyncio.gather( + device_coordinator.async_config_entry_first_refresh(), + firmware_coordinator.async_config_entry_first_refresh(), + ) except ConfigEntryNotReady: await host.stop() raise hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = ReolinkData( host=host, - device_coordinator=coordinator_device_config_update, + device_coordinator=device_coordinator, + firmware_coordinator=firmware_coordinator, ) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 5e7718f4180..541ad9ec998 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -116,7 +116,6 @@ async def async_setup_entry( class ReolinkBinarySensorEntity(ReolinkCoordinatorEntity, BinarySensorEntity): """Base binary-sensor class for Reolink IP camera motion sensors.""" - _attr_has_entity_name = True entity_description: ReolinkBinarySensorEntityDescription def __init__( diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 5ccada7269d..d14906a5782 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -43,7 +43,6 @@ class ReolinkCamera(ReolinkCoordinatorEntity, Camera): """An implementation of a Reolink IP camera.""" _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - _attr_has_entity_name = True def __init__( self, diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index bcf39814c9a..5f983ab3494 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -3,25 +3,69 @@ from __future__ import annotations from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from . import ReolinkData from .const import DOMAIN -class ReolinkCoordinatorEntity(CoordinatorEntity): - """Parent class for Reolink hardware camera entities.""" +class ReolinkBaseCoordinatorEntity(CoordinatorEntity): + """Parent class for entities that control the Reolink NVR itself, without a channel. - def __init__(self, reolink_data: ReolinkData, channel: int) -> None: - """Initialize ReolinkCoordinatorEntity for a hardware camera.""" - coordinator = reolink_data.device_coordinator + 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 + + def __init__( + self, + reolink_data: ReolinkData, + coordinator: DataUpdateCoordinator | None = None, + ) -> None: + """Initialize ReolinkBaseCoordinatorEntity for a NVR entity without a channel.""" + if coordinator is None: + coordinator = reolink_data.device_coordinator super().__init__(coordinator) self._host = reolink_data.host - self._channel = channel http_s = "https" if self._host.api.use_https else "http" - 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}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._host.unique_id)}, + connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)}, + name=self._host.api.nvr_name, + model=self._host.api.model, + manufacturer=self._host.api.manufacturer, + hw_version=self._host.api.hardware_version, + sw_version=self._host.api.sw_version, + configuration_url=self._conf_url, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._host.api.session_active and super().available + + +class ReolinkCoordinatorEntity(ReolinkBaseCoordinatorEntity): + """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + coordinator: DataUpdateCoordinator | None = None, + ) -> None: + """Initialize ReolinkCoordinatorEntity for a hardware camera connected to a channel of the NVR.""" + super().__init__(reolink_data, coordinator) + + self._channel = channel + if self._host.api.is_nvr: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{self._host.unique_id}_ch{self._channel}")}, @@ -29,21 +73,5 @@ class ReolinkCoordinatorEntity(CoordinatorEntity): name=self._host.api.camera_name(self._channel), model=self._host.api.camera_model(self._channel), manufacturer=self._host.api.manufacturer, - configuration_url=conf_url, + configuration_url=self._conf_url, ) - else: - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._host.unique_id)}, - connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)}, - name=self._host.api.nvr_name, - model=self._host.api.model, - manufacturer=self._host.api.manufacturer, - hw_version=self._host.api.hardware_version, - sw_version=self._host.api.sw_version, - configuration_url=conf_url, - ) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._host.api.session_active and super().available diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 3f8860876ae..e9b692fffe6 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -88,7 +88,6 @@ async def async_setup_entry( class ReolinkNumberEntity(ReolinkCoordinatorEntity, NumberEntity): """Base number entity class for Reolink IP cameras.""" - _attr_has_entity_name = True entity_description: ReolinkNumberEntityDescription def __init__( diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py new file mode 100644 index 00000000000..71ca16ca68d --- /dev/null +++ b/homeassistant/components/reolink/update.py @@ -0,0 +1,78 @@ +"""Update entities for Reolink devices.""" +from __future__ import annotations + +import logging +from typing import Any + +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkBaseCoordinatorEntity + +LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up update entities for Reolink component.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([ReolinkUpdateEntity(reolink_data)]) + + +class ReolinkUpdateEntity(ReolinkBaseCoordinatorEntity, UpdateEntity): + """Update entity for a Netgear device.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = UpdateEntityFeature.INSTALL + _attr_release_url = "https://reolink.com/download-center/" + _attr_name = "Update" + + def __init__( + self, + reolink_data: ReolinkData, + ) -> None: + """Initialize a Netgear device.""" + super().__init__(reolink_data, reolink_data.firmware_coordinator) + + self._attr_unique_id = f"{self._host.unique_id}_update" + + @property + def installed_version(self) -> str | None: + """Version currently in use.""" + return self._host.api.sw_version + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + if self.coordinator.data is None: + return None + + if not self.coordinator.data: + return self.installed_version + + return self.coordinator.data + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the latest firmware version.""" + try: + await self._host.api.update_firmware() + except ReolinkError as err: + raise HomeAssistantError( + f"Error trying to update Reolink firmware: {err}" + ) from err diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 10391c32067..a5de5d5acb8 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -36,6 +36,7 @@ def get_mock_info(error=None, user_level="admin"): host_mock.get_host_data = AsyncMock(return_value=None) else: host_mock.get_host_data = AsyncMock(side_effect=error) + host_mock.check_new_firmware = AsyncMock(return_value=False) host_mock.unsubscribe = AsyncMock(return_value=True) host_mock.logout = AsyncMock(return_value=True) host_mock.mac_address = TEST_MAC