From 8c0d9a13203980d17027518427fe10dce2ee249c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 31 Jul 2024 17:04:09 +0200 Subject: [PATCH] Add Reolink chime support (#122752) --- homeassistant/components/reolink/__init__.py | 16 +- .../components/reolink/binary_sensor.py | 16 +- homeassistant/components/reolink/button.py | 8 +- homeassistant/components/reolink/entity.py | 44 ++++- homeassistant/components/reolink/icons.json | 12 ++ homeassistant/components/reolink/number.py | 75 +++++++- homeassistant/components/reolink/select.py | 100 ++++++++++- homeassistant/components/reolink/sensor.py | 18 +- homeassistant/components/reolink/strings.json | 51 ++++++ homeassistant/components/reolink/switch.py | 79 ++++++++- homeassistant/components/reolink/update.py | 8 +- tests/components/reolink/test_init.py | 9 + tests/components/reolink/test_select.py | 167 ++++++++++++++++++ 13 files changed, 549 insertions(+), 54 deletions(-) create mode 100644 tests/components/reolink/test_select.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 2077b4a5e29..cc293d970b2 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -186,7 +186,7 @@ async def async_remove_config_entry_device( ) -> bool: """Remove a device from a config entry.""" host: ReolinkHost = hass.data[DOMAIN][config_entry.entry_id].host - (device_uid, ch) = get_device_uid_and_ch(device, host) + (device_uid, ch, is_chime) = get_device_uid_and_ch(device, host) if not host.api.is_nvr or ch is None: _LOGGER.warning( @@ -227,20 +227,24 @@ async def async_remove_config_entry_device( def get_device_uid_and_ch( device: dr.DeviceEntry, host: ReolinkHost -) -> tuple[list[str], int | None]: +) -> tuple[list[str], int | None, bool]: """Get the channel and the split device_uid from a reolink DeviceEntry.""" device_uid = [ dev_id[1].split("_") for dev_id in device.identifiers if dev_id[0] == DOMAIN ][0] + is_chime = False if len(device_uid) < 2: # NVR itself ch = None elif device_uid[1].startswith("ch") and len(device_uid[1]) <= 5: ch = int(device_uid[1][2:]) + elif device_uid[1].startswith("chime"): + ch = int(device_uid[1][5:]) + is_chime = True else: ch = host.api.channel_for_uid(device_uid[1]) - return (device_uid, ch) + return (device_uid, ch, is_chime) def migrate_entity_ids( @@ -251,7 +255,7 @@ def migrate_entity_ids( devices = dr.async_entries_for_config_entry(device_reg, config_entry_id) ch_device_ids = {} for device in devices: - (device_uid, ch) = get_device_uid_and_ch(device, host) + (device_uid, ch, is_chime) = get_device_uid_and_ch(device, host) if host.api.supported(None, "UID") and device_uid[0] != host.unique_id: if ch is None: @@ -261,8 +265,8 @@ def migrate_entity_ids( new_identifiers = {(DOMAIN, new_device_id)} device_reg.async_update_device(device.id, new_identifiers=new_identifiers) - if ch is None: - continue # Do not consider the NVR itself + if ch is None or is_chime: + continue # Do not consider the NVR itself or chimes ch_device_ids[device.id] = ch if host.api.supported(ch, "UID") and device_uid[1] != host.api.camera_uid(ch): diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index d19987c3bc6..70c21849bc2 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -117,18 +117,14 @@ async def async_setup_entry( entities: list[ReolinkBinarySensorEntity] = [] for channel in reolink_data.host.api.channels: entities.extend( - [ - ReolinkPushBinarySensorEntity(reolink_data, channel, entity_description) - for entity_description in BINARY_PUSH_SENSORS - if entity_description.supported(reolink_data.host.api, channel) - ] + ReolinkPushBinarySensorEntity(reolink_data, channel, entity_description) + for entity_description in BINARY_PUSH_SENSORS + if entity_description.supported(reolink_data.host.api, channel) ) entities.extend( - [ - ReolinkBinarySensorEntity(reolink_data, channel, entity_description) - for entity_description in BINARY_SENSORS - if entity_description.supported(reolink_data.host.api, channel) - ] + ReolinkBinarySensorEntity(reolink_data, channel, entity_description) + for entity_description in BINARY_SENSORS + if entity_description.supported(reolink_data.host.api, channel) ) async_add_entities(entities) diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 528807920d3..eba0570a3fb 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -164,11 +164,9 @@ async def async_setup_entry( if entity_description.supported(reolink_data.host.api, channel) ] entities.extend( - [ - ReolinkHostButtonEntity(reolink_data, entity_description) - for entity_description in HOST_BUTTON_ENTITIES - if entity_description.supported(reolink_data.host.api) - ] + ReolinkHostButtonEntity(reolink_data, entity_description) + for entity_description in HOST_BUTTON_ENTITIES + if entity_description.supported(reolink_data.host.api) ) async_add_entities(entities) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index c07983175ae..053792ad667 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from reolink_aio.api import DUAL_LENS_MODELS, Host +from reolink_aio.api import DUAL_LENS_MODELS, Chime, Host from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import EntityDescription @@ -59,8 +59,9 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] 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._dev_id = self._host.unique_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._host.unique_id)}, + 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, @@ -126,12 +127,14 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): if self._host.api.is_nvr: if self._host.api.supported(dev_ch, "UID"): - dev_id = f"{self._host.unique_id}_{self._host.api.camera_uid(dev_ch)}" + self._dev_id = ( + f"{self._host.unique_id}_{self._host.api.camera_uid(dev_ch)}" + ) else: - dev_id = f"{self._host.unique_id}_ch{dev_ch}" + self._dev_id = f"{self._host.unique_id}_ch{dev_ch}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, dev_id)}, + identifiers={(DOMAIN, self._dev_id)}, via_device=(DOMAIN, self._host.unique_id), name=self._host.api.camera_name(dev_ch), model=self._host.api.camera_model(dev_ch), @@ -156,3 +159,34 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): self._host.async_unregister_update_cmd(cmd_key, self._channel) await super().async_will_remove_from_hass() + + +class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity): + """Parent class for Reolink chime entities connected.""" + + def __init__( + self, + reolink_data: ReolinkData, + chime: Chime, + coordinator: DataUpdateCoordinator[None] | None = None, + ) -> None: + """Initialize ReolinkChimeCoordinatorEntity for a chime.""" + 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}" + ) + cam_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=(DOMAIN, cam_dev_id), + name=chime.name, + model="Reolink Chime", + manufacturer=self._host.api.manufacturer, + serial_number=str(chime.dev_id), + configuration_url=self._conf_url, + ) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 539c2461204..7ca4c2d7f2b 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -206,6 +206,15 @@ }, "hdr": { "default": "mdi:hdr" + }, + "motion_tone": { + "default": "mdi:music-note" + }, + "people_tone": { + "default": "mdi:music-note" + }, + "visitor_tone": { + "default": "mdi:music-note" } }, "sensor": { @@ -284,6 +293,9 @@ }, "pir_reduce_alarm": { "default": "mdi:motion-sensor" + }, + "led": { + "default": "mdi:lightning-bolt-circle" } } }, diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index a4ea89c5b26..1dc99c886e1 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from reolink_aio.api import Host +from reolink_aio.api import Chime, Host from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.number import ( @@ -22,7 +22,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkChimeCoordinatorEntity, +) @dataclass(frozen=True, kw_only=True) @@ -39,6 +43,18 @@ class ReolinkNumberEntityDescription( value: Callable[[Host, int], float | None] +@dataclass(frozen=True, kw_only=True) +class ReolinkChimeNumberEntityDescription( + NumberEntityDescription, + ReolinkChannelEntityDescription, +): + """A class that describes number entities for a chime.""" + + method: Callable[[Chime, float], Any] + mode: NumberMode = NumberMode.AUTO + value: Callable[[Chime], float | None] + + NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="zoom", @@ -459,6 +475,20 @@ NUMBER_ENTITIES = ( ), ) +CHIME_NUMBER_ENTITIES = ( + ReolinkChimeNumberEntityDescription( + key="volume", + cmd_key="DingDongOpt", + translation_key="volume", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=4, + value=lambda chime: chime.volume, + method=lambda chime, value: chime.set_option(volume=int(value)), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -468,12 +498,18 @@ async def async_setup_entry( """Set up a Reolink number entities.""" reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( + entities: list[ReolinkNumberEntity | ReolinkChimeNumberEntity] = [ ReolinkNumberEntity(reolink_data, channel, entity_description) for entity_description in NUMBER_ENTITIES for channel in reolink_data.host.api.channels if entity_description.supported(reolink_data.host.api, channel) + ] + entities.extend( + ReolinkChimeNumberEntity(reolink_data, chime, entity_description) + for entity_description in CHIME_NUMBER_ENTITIES + for chime in reolink_data.host.api.chime_list ) + async_add_entities(entities) class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): @@ -515,3 +551,36 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): except ReolinkError as err: raise HomeAssistantError(err) from err self.async_write_ha_state() + + +class ReolinkChimeNumberEntity(ReolinkChimeCoordinatorEntity, NumberEntity): + """Base number entity class for Reolink IP cameras.""" + + entity_description: ReolinkChimeNumberEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + chime: Chime, + entity_description: ReolinkChimeNumberEntityDescription, + ) -> None: + """Initialize Reolink chime number entity.""" + self.entity_description = entity_description + super().__init__(reolink_data, chime) + + self._attr_mode = entity_description.mode + + @property + def native_value(self) -> float | None: + """State of the number entity.""" + return self.entity_description.value(self._chime) + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + try: + await self.entity_description.method(self._chime, value) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err + self.async_write_ha_state() diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index cf32d7b45f9..94cfdf6751b 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -8,6 +8,8 @@ import logging from typing import Any from reolink_aio.api import ( + Chime, + ChimeToneEnum, DayNightEnum, HDREnum, Host, @@ -26,7 +28,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkChimeCoordinatorEntity, +) _LOGGER = logging.getLogger(__name__) @@ -43,6 +49,18 @@ class ReolinkSelectEntityDescription( value: Callable[[Host, int], str] | None = None +@dataclass(frozen=True, kw_only=True) +class ReolinkChimeSelectEntityDescription( + SelectEntityDescription, + ReolinkChannelEntityDescription, +): + """A class that describes select entities for a chime.""" + + get_options: list[str] + method: Callable[[Chime, str], Any] + value: Callable[[Chime], str] + + def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: """Get the quick reply file id from the message string.""" return [k for k, v in api.quick_reply_dict(ch).items() if v == mess][0] @@ -132,6 +150,36 @@ SELECT_ENTITIES = ( ), ) +CHIME_SELECT_ENTITIES = ( + ReolinkChimeSelectEntityDescription( + key="motion_tone", + cmd_key="GetDingDongCfg", + translation_key="motion_tone", + entity_category=EntityCategory.CONFIG, + get_options=[method.name for method in ChimeToneEnum], + value=lambda chime: ChimeToneEnum(chime.tone("md")).name, + method=lambda chime, name: chime.set_tone("md", ChimeToneEnum[name].value), + ), + ReolinkChimeSelectEntityDescription( + key="people_tone", + cmd_key="GetDingDongCfg", + translation_key="people_tone", + entity_category=EntityCategory.CONFIG, + get_options=[method.name for method in ChimeToneEnum], + value=lambda chime: ChimeToneEnum(chime.tone("people")).name, + method=lambda chime, name: chime.set_tone("people", ChimeToneEnum[name].value), + ), + ReolinkChimeSelectEntityDescription( + key="visitor_tone", + cmd_key="GetDingDongCfg", + translation_key="visitor_tone", + entity_category=EntityCategory.CONFIG, + get_options=[method.name for method in ChimeToneEnum], + value=lambda chime: ChimeToneEnum(chime.tone("visitor")).name, + method=lambda chime, name: chime.set_tone("visitor", ChimeToneEnum[name].value), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -141,12 +189,18 @@ async def async_setup_entry( """Set up a Reolink select entities.""" reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( + entities: list[ReolinkSelectEntity | ReolinkChimeSelectEntity] = [ ReolinkSelectEntity(reolink_data, channel, entity_description) for entity_description in SELECT_ENTITIES for channel in reolink_data.host.api.channels if entity_description.supported(reolink_data.host.api, channel) + ] + entities.extend( + ReolinkChimeSelectEntity(reolink_data, chime, entity_description) + for entity_description in CHIME_SELECT_ENTITIES + for chime in reolink_data.host.api.chime_list ) + async_add_entities(entities) class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): @@ -196,3 +250,45 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): except ReolinkError as err: raise HomeAssistantError(err) from err self.async_write_ha_state() + + +class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity): + """Base select entity class for Reolink IP cameras.""" + + entity_description: ReolinkChimeSelectEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + chime: Chime, + entity_description: ReolinkChimeSelectEntityDescription, + ) -> None: + """Initialize Reolink select entity for a chime.""" + self.entity_description = entity_description + super().__init__(reolink_data, chime) + self._log_error = True + self._attr_options = entity_description.get_options + + @property + def current_option(self) -> str | None: + """Return the current option.""" + try: + option = self.entity_description.value(self._chime) + except ValueError: + if self._log_error: + _LOGGER.exception("Reolink '%s' has an unknown value", self.name) + self._log_error = False + return None + + self._log_error = True + return option + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + try: + await self.entity_description.method(self._chime, option) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err + self.async_write_ha_state() diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 419270a7082..988b091735e 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -141,19 +141,15 @@ async def async_setup_entry( if entity_description.supported(reolink_data.host.api, channel) ] entities.extend( - [ - ReolinkHostSensorEntity(reolink_data, entity_description) - for entity_description in HOST_SENSORS - if entity_description.supported(reolink_data.host.api) - ] + ReolinkHostSensorEntity(reolink_data, entity_description) + for entity_description in HOST_SENSORS + if entity_description.supported(reolink_data.host.api) ) entities.extend( - [ - ReolinkHddSensorEntity(reolink_data, hdd_index, entity_description) - for entity_description in HDD_SENSORS - for hdd_index in reolink_data.host.api.hdd_list - if entity_description.supported(reolink_data.host.api, hdd_index) - ] + ReolinkHddSensorEntity(reolink_data, hdd_index, entity_description) + for entity_description in HDD_SENSORS + for hdd_index in reolink_data.host.api.hdd_list + if entity_description.supported(reolink_data.host.api, hdd_index) ) async_add_entities(entities) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index bcf1c71934d..cad09f71562 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -491,6 +491,54 @@ "on": "[%key:common::state::on%]", "auto": "Auto" } + }, + "motion_tone": { + "name": "Motion ringtone", + "state": { + "off": "[%key:common::state::off%]", + "citybird": "City bird", + "originaltune": "Original tune", + "pianokey": "Piano key", + "loop": "Loop", + "attraction": "Attraction", + "hophop": "Hop hop", + "goodday": "Good day", + "operetta": "Operetta", + "moonlight": "Moonlight", + "waybackhome": "Way back home" + } + }, + "people_tone": { + "name": "Person ringtone", + "state": { + "off": "[%key:common::state::off%]", + "citybird": "[%key:component::reolink::entity::select::motion_tone::state::citybird%]", + "originaltune": "[%key:component::reolink::entity::select::motion_tone::state::originaltune%]", + "pianokey": "[%key:component::reolink::entity::select::motion_tone::state::pianokey%]", + "loop": "[%key:component::reolink::entity::select::motion_tone::state::loop%]", + "attraction": "[%key:component::reolink::entity::select::motion_tone::state::attraction%]", + "hophop": "[%key:component::reolink::entity::select::motion_tone::state::hophop%]", + "goodday": "[%key:component::reolink::entity::select::motion_tone::state::goodday%]", + "operetta": "[%key:component::reolink::entity::select::motion_tone::state::operetta%]", + "moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]", + "waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]" + } + }, + "visitor_tone": { + "name": "Visitor ringtone", + "state": { + "off": "[%key:common::state::off%]", + "citybird": "[%key:component::reolink::entity::select::motion_tone::state::citybird%]", + "originaltune": "[%key:component::reolink::entity::select::motion_tone::state::originaltune%]", + "pianokey": "[%key:component::reolink::entity::select::motion_tone::state::pianokey%]", + "loop": "[%key:component::reolink::entity::select::motion_tone::state::loop%]", + "attraction": "[%key:component::reolink::entity::select::motion_tone::state::attraction%]", + "hophop": "[%key:component::reolink::entity::select::motion_tone::state::hophop%]", + "goodday": "[%key:component::reolink::entity::select::motion_tone::state::goodday%]", + "operetta": "[%key:component::reolink::entity::select::motion_tone::state::operetta%]", + "moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]", + "waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]" + } } }, "sensor": { @@ -574,6 +622,9 @@ }, "pir_reduce_alarm": { "name": "PIR reduce false alarm" + }, + "led": { + "name": "LED" } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index cd74d774bb1..2bf7689b32f 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from reolink_aio.api import Host +from reolink_aio.api import Chime, Host from reolink_aio.exceptions import ReolinkError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription @@ -22,6 +22,7 @@ from .const import DOMAIN from .entity import ( ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription, + ReolinkChimeCoordinatorEntity, ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) @@ -49,6 +50,17 @@ class ReolinkNVRSwitchEntityDescription( value: Callable[[Host], bool] +@dataclass(frozen=True, kw_only=True) +class ReolinkChimeSwitchEntityDescription( + SwitchEntityDescription, + ReolinkChannelEntityDescription, +): + """A class that describes switch entities for a chime.""" + + method: Callable[[Chime, bool], Any] + value: Callable[[Chime], bool | None] + + SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="ir_lights", @@ -245,6 +257,17 @@ NVR_SWITCH_ENTITIES = ( ), ) +CHIME_SWITCH_ENTITIES = ( + ReolinkChimeSwitchEntityDescription( + key="chime_led", + cmd_key="DingDongOpt", + translation_key="led", + entity_category=EntityCategory.CONFIG, + value=lambda chime: chime.led_state, + method=lambda chime, value: chime.set_option(led=value), + ), +) + # Can be removed in HA 2025.2.0 DEPRECATED_HDR = ReolinkSwitchEntityDescription( key="hdr", @@ -266,18 +289,23 @@ async def async_setup_entry( """Set up a Reolink switch entities.""" reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] - entities: list[ReolinkSwitchEntity | ReolinkNVRSwitchEntity] = [ + entities: list[ + ReolinkSwitchEntity | ReolinkNVRSwitchEntity | ReolinkChimeSwitchEntity + ] = [ ReolinkSwitchEntity(reolink_data, channel, entity_description) for entity_description in SWITCH_ENTITIES for channel in reolink_data.host.api.channels if entity_description.supported(reolink_data.host.api, channel) ] entities.extend( - [ - ReolinkNVRSwitchEntity(reolink_data, entity_description) - for entity_description in NVR_SWITCH_ENTITIES - if entity_description.supported(reolink_data.host.api) - ] + ReolinkNVRSwitchEntity(reolink_data, entity_description) + for entity_description in NVR_SWITCH_ENTITIES + if entity_description.supported(reolink_data.host.api) + ) + entities.extend( + ReolinkChimeSwitchEntity(reolink_data, chime, entity_description) + for entity_description in CHIME_SWITCH_ENTITIES + for chime in reolink_data.host.api.chime_list ) # Can be removed in HA 2025.2.0 @@ -378,3 +406,40 @@ class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): except ReolinkError as err: raise HomeAssistantError(err) from err self.async_write_ha_state() + + +class ReolinkChimeSwitchEntity(ReolinkChimeCoordinatorEntity, SwitchEntity): + """Base switch entity class for a chime.""" + + entity_description: ReolinkChimeSwitchEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + chime: Chime, + entity_description: ReolinkChimeSwitchEntityDescription, + ) -> None: + """Initialize Reolink switch entity.""" + self.entity_description = entity_description + super().__init__(reolink_data, chime) + + @property + def is_on(self) -> bool | None: + """Return true if switch is on.""" + return self.entity_description.value(self._chime) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + try: + await self.entity_description.method(self._chime, True) + except ReolinkError as err: + raise HomeAssistantError(err) from err + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + try: + await self.entity_description.method(self._chime, False) + except ReolinkError as err: + raise HomeAssistantError(err) from err + self.async_write_ha_state() diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index da3dafe0130..9b710c6576d 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -81,11 +81,9 @@ async def async_setup_entry( if entity_description.supported(reolink_data.host.api, channel) ] entities.extend( - [ - ReolinkHostUpdateEntity(reolink_data, entity_description) - for entity_description in HOST_UPDATE_ENTITIES - if entity_description.supported(reolink_data.host.api) - ] + ReolinkHostUpdateEntity(reolink_data, entity_description) + for entity_description in HOST_UPDATE_ENTITIES + if entity_description.supported(reolink_data.host.api) ) async_add_entities(entities) diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 85ce5d94657..4f745530b6b 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -282,6 +282,15 @@ async def test_removing_disconnected_cams( True, False, ), + ( + f"{TEST_MAC}_chime123456789_play_ringtone", + f"{TEST_UID}_chime123456789_play_ringtone", + f"{TEST_MAC}_chime123456789", + f"{TEST_UID}_chime123456789", + Platform.SELECT, + True, + False, + ), ( f"{TEST_MAC}_0_record_audio", f"{TEST_MAC}_{TEST_UID_CAM}_record_audio", diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py new file mode 100644 index 00000000000..908c06dc16f --- /dev/null +++ b/tests/components/reolink/test_select.py @@ -0,0 +1,167 @@ +"""Test the Reolink select platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from reolink_aio.api import Chime +from reolink_aio.exceptions import InvalidParameterError, ReolinkError + +from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_SELECT_OPTION, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.util.dt import utcnow + +from .conftest import TEST_NVR_NAME + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_floodlight_mode_select( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test select entity with floodlight_mode.""" + reolink_connect.whiteled_mode.return_value = 1 + reolink_connect.whiteled_mode_list.return_value = ["off", "auto"] + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SELECT}.{TEST_NVR_NAME}_floodlight_mode" + assert hass.states.is_state(entity_id, "auto") + + reolink_connect.set_whiteled = AsyncMock() + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "off"}, + blocking=True, + ) + reolink_connect.set_whiteled.assert_called_once() + + reolink_connect.set_whiteled = AsyncMock(side_effect=ReolinkError("Test error")) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "off"}, + blocking=True, + ) + + reolink_connect.set_whiteled = AsyncMock( + side_effect=InvalidParameterError("Test error") + ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "off"}, + blocking=True, + ) + + +async def test_play_quick_reply_message( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test select play_quick_reply_message entity.""" + reolink_connect.quick_reply_dict.return_value = {0: "off", 1: "test message"} + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SELECT}.{TEST_NVR_NAME}_play_quick_reply_message" + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + + reolink_connect.play_quick_reply = AsyncMock() + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "test message"}, + blocking=True, + ) + reolink_connect.play_quick_reply.assert_called_once() + + +async def test_chime_select( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test chime select entity.""" + TEST_CHIME = Chime( + host=reolink_connect, + dev_id=12345678, + channel=0, + name="Test chime", + event_info={ + "md": {"switch": 0, "musicId": 0}, + "people": {"switch": 0, "musicId": 1}, + "visitor": {"switch": 1, "musicId": 2}, + }, + ) + TEST_CHIME.volume = 3 + TEST_CHIME.led_state = True + + reolink_connect.chime_list = [TEST_CHIME] + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SELECT}.test_chime_visitor_ringtone" + assert hass.states.is_state(entity_id, "pianokey") + + TEST_CHIME.set_tone = AsyncMock() + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "off"}, + blocking=True, + ) + TEST_CHIME.set_tone.assert_called_once() + + TEST_CHIME.set_tone = AsyncMock(side_effect=ReolinkError("Test error")) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "off"}, + blocking=True, + ) + + TEST_CHIME.set_tone = AsyncMock(side_effect=InvalidParameterError("Test error")) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "off"}, + blocking=True, + ) + + TEST_CHIME.event_info = {} + async_fire_time_changed( + hass, utcnow() + DEVICE_UPDATE_INTERVAL + timedelta(seconds=30) + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, STATE_UNKNOWN)