"""Reolink parent entity class.""" from collections.abc import Callable from dataclasses import dataclass from typing import override from reolink_aio.api import DUAL_LENS_DUAL_MOTION_MODELS, Chime, Host from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ReolinkCoordinator from .util import ReolinkData @dataclass(frozen=True, kw_only=True) class ReolinkEntityDescription(EntityDescription): """A class that describes entities for Reolink.""" cmd_key: str | None = None cmd_id: int | list[int] | None = None always_available: bool = False # Whether the entity measures a property of a single lens # of a dual lens camera, instead of the camera as a whole lens_entity: bool = False @dataclass(frozen=True, kw_only=True) class ReolinkChannelEntityDescription(ReolinkEntityDescription): """A class that describes entities for a camera channel.""" supported: Callable[[Host, int], bool] = lambda api, ch: True @dataclass(frozen=True, kw_only=True) class ReolinkHostEntityDescription(ReolinkEntityDescription): """A class that describes host entities.""" supported: Callable[[Host], bool] = lambda api: True @dataclass(frozen=True, kw_only=True) class ReolinkChimeEntityDescription(ReolinkEntityDescription): """A class that describes entities for a chime.""" supported: Callable[[Chime], bool] = lambda chime: True class ReolinkHostCoordinatorEntity(CoordinatorEntity[ReolinkCoordinator]): """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: ReolinkEntityDescription def __init__( self, reolink_data: ReolinkData, coordinator: ReolinkCoordinator | None = None, ) -> None: """Initialize ReolinkHostCoordinatorEntity.""" if coordinator is None: coordinator = reolink_data.device_coordinator super().__init__(coordinator) self._host = reolink_data.host self._attr_unique_id: str = ( f"{self._host.unique_id}_{self.entity_description.key}" ) http_s = "https" if self._host.api.use_https else "http" if self._host.api.baichuan_only: self._conf_url = None else: self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" self._dev_id = self._host.unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)}, name=self._host.api.nvr_name, model=self._host.api.model, model_id=self._host.api.item_number(), manufacturer=self._host.api.manufacturer, hw_version=self._host.api.hardware_version, sw_version=self._host.api.sw_version, serial_number=self._host.api.uid, configuration_url=self._conf_url, ) @property @override def available(self) -> bool: """Return True if entity is available.""" if self.entity_description.always_available: return True if self._host.api.is_battery: return ( self._host.api.baichuan.login_sucess and not self._host.api.baichuan.privacy_mode() and super().available ) return ( self._host.api.session_active and not self._host.api.baichuan.privacy_mode() and super().available ) @callback def _push_callback(self) -> None: """Handle incoming TCP push event.""" self.async_write_ha_state() def register_callback(self, callback_id: str, cmd_id: int) -> None: """Register callback for TCP push events.""" self._host.api.baichuan.register_callback( # pragma: no cover callback_id, self._push_callback, cmd_id ) @override async def async_added_to_hass(self) -> None: """Entity created.""" await super().async_added_to_hass() cmd_key = self.entity_description.cmd_key cmd_ids = self.entity_description.cmd_id callback_id = f"{self.platform.domain}_{self._attr_unique_id}" if cmd_key is not None: self._host.async_register_update_cmd(cmd_key) if isinstance(cmd_ids, int): self.register_callback(callback_id, cmd_ids) elif isinstance(cmd_ids, list): for cmd_id in cmd_ids: self.register_callback(callback_id, cmd_id) # Privacy mode self.register_callback(f"{callback_id}_623", 623) @override async def async_will_remove_from_hass(self) -> None: """Entity removed.""" cmd_key = self.entity_description.cmd_key cmd_id = self.entity_description.cmd_id callback_id = f"{self.platform.domain}_{self._attr_unique_id}" if cmd_key is not None: self._host.async_unregister_update_cmd(cmd_key) if cmd_id is not None: self._host.api.baichuan.unregister_callback(callback_id) # Privacy mode self._host.api.baichuan.unregister_callback(f"{callback_id}_623") await super().async_will_remove_from_hass() @override async def async_update(self) -> None: """Force full update from the generic entity update service.""" for channel in self._host.api.channels: if self._host.api.supported(channel, "battery"): self._host.last_wake[channel] = 0 await super().async_update() class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink camera entities connected to a NVR channel.""" def __init__( self, reolink_data: ReolinkData, channel: int, coordinator: ReolinkCoordinator | None = None, ) -> None: """Initialize ReolinkChannelCoordinatorEntity.""" super().__init__(reolink_data, coordinator) self._channel = channel if self._host.api.is_nvr and self._host.api.supported(channel, "UID"): self._attr_unique_id = ( f"{self._host.unique_id}" f"_{self._host.api.camera_uid(channel)}" f"_{self.entity_description.key}" ) else: self._attr_unique_id = ( f"{self._host.unique_id}_{channel}_{self.entity_description.key}" ) if self._host.api.is_nvr: if self._host.api.supported(channel, "UID"): self._dev_id = ( f"{self._host.unique_id}_{self._host.api.camera_uid(channel)}" ) else: self._dev_id = f"{self._host.unique_id}_ch{channel}" connections = set() if mac := self._host.api.baichuan.mac_address(channel): connections.add((CONNECTION_NETWORK_MAC, mac)) if self._conf_url is None: conf_url = None else: conf_url = f"{self._conf_url}/?ch={channel}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, connections=connections, via_device_id=dr.async_get_device_id_by_identifier( self.coordinator.hass, (DOMAIN, self._host.unique_id), config_entry_id=self.coordinator.config_entry.entry_id, ), name=self._host.api.camera_name(channel), model=self._host.api.camera_model(channel), model_id=self._host.api.item_number(channel), manufacturer=self._host.api.manufacturer, hw_version=self._host.api.camera_hardware_version(channel), sw_version=self._host.api.camera_sw_version(channel), serial_number=self._host.api.camera_uid(channel), configuration_url=conf_url, ) if ( self.entity_description.lens_entity and self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS ): # Dual lens cameras with separate sensors per lens # use a sub-device per lens parent_dev_id = self._dev_id self._dev_id = f"{self._host.unique_id}_lens{channel}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, via_device_id=dr.async_get_device_id_by_identifier( self.coordinator.hass, (DOMAIN, parent_dev_id), config_entry_id=self.coordinator.config_entry.entry_id, ), name=f"{self._host.api.camera_name(0)} lens {channel}", model=self._host.api.camera_model(0), manufacturer=self._host.api.manufacturer, configuration_url=self._conf_url, ) @property @override def available(self) -> bool: """Return True if entity is available.""" if self.entity_description.always_available: return True return ( super().available and self._host.api.camera_online(self._channel) and not self._host.api.baichuan.privacy_mode(self._channel) ) @override def register_callback(self, callback_id: str, cmd_id: int) -> None: """Register callback for TCP push events.""" self._host.api.baichuan.register_callback( callback_id, self._push_callback, cmd_id, self._channel ) @override async def async_added_to_hass(self) -> None: """Entity created.""" await super().async_added_to_hass() cmd_key = self.entity_description.cmd_key if cmd_key is not None: self._host.async_register_update_cmd(cmd_key, self._channel) @override async def async_will_remove_from_hass(self) -> None: """Entity removed.""" cmd_key = self.entity_description.cmd_key if cmd_key is not None: self._host.async_unregister_update_cmd(cmd_key, self._channel) await super().async_will_remove_from_hass() class ReolinkHostChimeCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink chime entities connected to a Host.""" def __init__( self, reolink_data: ReolinkData, chime: Chime, coordinator: ReolinkCoordinator | None = None, ) -> None: """Initialize ReolinkHostChimeCoordinatorEntity for a chime.""" super().__init__(reolink_data, coordinator) self._channel = chime.channel self._chime = chime self._attr_unique_id = ( f"{self._host.unique_id}_chime{chime.dev_id}_{self.entity_description.key}" ) via_dev_id = self._host.unique_id self._dev_id = f"{self._host.unique_id}_chime{chime.dev_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, via_device_id=dr.async_get_device_id_by_identifier( self.coordinator.hass, (DOMAIN, via_dev_id), config_entry_id=self.coordinator.config_entry.entry_id, ), name=chime.name, model="Reolink Chime", manufacturer=self._host.api.manufacturer, sw_version=chime.sw_version, serial_number=str(chime.dev_id), configuration_url=self._conf_url, ) @property @override def available(self) -> bool: """Return True if entity is available.""" return super().available and self._chime.online class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity): """Parent class for Reolink chime entities connected through a camera.""" def __init__( self, reolink_data: ReolinkData, chime: Chime, coordinator: ReolinkCoordinator | None = None, ) -> None: """Initialize ReolinkChimeCoordinatorEntity for a chime.""" assert chime.channel is not None super().__init__(reolink_data, chime.channel, coordinator) self._chime = chime self._attr_unique_id = ( f"{self._host.unique_id}_chime{chime.dev_id}_{self.entity_description.key}" ) via_dev_id = self._dev_id self._dev_id = f"{self._host.unique_id}_chime{chime.dev_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, via_device_id=dr.async_get_device_id_by_identifier( self.coordinator.hass, (DOMAIN, via_dev_id), config_entry_id=self.coordinator.config_entry.entry_id, ), name=chime.name, model="Reolink Chime", manufacturer=self._host.api.manufacturer, sw_version=chime.sw_version, serial_number=str(chime.dev_id), configuration_url=self._conf_url, ) @property @override def available(self) -> bool: """Return True if entity is available.""" return self._chime.online and super().available