From 7334fb01250e07f771e3319061af9be06e20e032 Mon Sep 17 00:00:00 2001 From: starkillerOG <starkiller.og@gmail.com> Date: Mon, 26 Aug 2024 21:12:32 +0200 Subject: [PATCH] Add Reolink chime play action (#123245) * Add chime play service * fix supported_feature * finalize * add tests * Adjust to device service * fix issue * Add tests * actions -> services * fix styling * Use conftest fixture for test_chime * Update tests/components/reolink/test_services.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * use ATTR_RINGTONE and rename chime_play to play_chime * Add translatable exceptions * fix styling * Remove option to use entity_id * fixes * Fix translations * fix * fix translation key * remove translation key * use callback for async_setup_services * fix styling * Add test_play_chime_service_unloaded --------- Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> --- homeassistant/components/reolink/__init__.py | 44 +++---- homeassistant/components/reolink/icons.json | 3 +- homeassistant/components/reolink/services.py | 80 ++++++++++++ .../components/reolink/services.yaml | 27 ++++ homeassistant/components/reolink/strings.json | 38 ++++++ homeassistant/components/reolink/util.py | 37 +++++- tests/components/reolink/test_select.py | 2 + tests/components/reolink/test_services.py | 116 ++++++++++++++++++ 8 files changed, 315 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/reolink/services.py create mode 100644 tests/components/reolink/test_services.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index a319024633c..f64c6bd9cf3 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass from datetime import timedelta import logging @@ -14,13 +13,20 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost +from .services import async_setup_services +from .util import ReolinkData, get_device_uid_and_ch _LOGGER = logging.getLogger(__name__) @@ -40,14 +46,14 @@ DEVICE_UPDATE_INTERVAL = timedelta(seconds=60) FIRMWARE_UPDATE_INTERVAL = timedelta(hours=12) NUM_CRED_ERRORS = 3 +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -@dataclass -class ReolinkData: - """Data for the Reolink integration.""" - host: ReolinkHost - device_coordinator: DataUpdateCoordinator[None] - firmware_coordinator: DataUpdateCoordinator[None] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Reolink shared code.""" + + async_setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -265,28 +271,6 @@ async def async_remove_config_entry_device( return False -def get_device_uid_and_ch( - device: dr.DeviceEntry, host: ReolinkHost -) -> 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, is_chime) - - def migrate_entity_ids( hass: HomeAssistant, config_entry_id: str, host: ReolinkHost ) -> None: diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 7ca4c2d7f2b..f7729789c4e 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -300,6 +300,7 @@ } }, "services": { - "ptz_move": "mdi:pan" + "ptz_move": "mdi:pan", + "play_chime": "mdi:music" } } diff --git a/homeassistant/components/reolink/services.py b/homeassistant/components/reolink/services.py new file mode 100644 index 00000000000..d5cb402c74b --- /dev/null +++ b/homeassistant/components/reolink/services.py @@ -0,0 +1,80 @@ +"""Reolink additional services.""" + +from __future__ import annotations + +from reolink_aio.api import Chime +from reolink_aio.enums import ChimeToneEnum +from reolink_aio.exceptions import InvalidParameterError, ReolinkError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN +from .host import ReolinkHost +from .util import get_device_uid_and_ch + +ATTR_RINGTONE = "ringtone" + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up Reolink services.""" + + async def async_play_chime(service_call: ServiceCall) -> None: + """Play a ringtone.""" + service_data = service_call.data + device_registry = dr.async_get(hass) + + for device_id in service_data[ATTR_DEVICE_ID]: + config_entry = None + device = device_registry.async_get(device_id) + if device is not None: + for entry_id in device.config_entries: + config_entry = hass.config_entries.async_get_entry(entry_id) + if config_entry is not None and config_entry.domain == DOMAIN: + break + if ( + config_entry is None + or device is None + or config_entry.state == ConfigEntryState.NOT_LOADED + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_entry_ex", + translation_placeholders={"service_name": "play_chime"}, + ) + host: ReolinkHost = hass.data[DOMAIN][config_entry.entry_id].host + (device_uid, chime_id, is_chime) = get_device_uid_and_ch(device, host) + chime: Chime | None = host.api.chime(chime_id) + if not is_chime or chime is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_not_chime", + translation_placeholders={"device_name": str(device.name)}, + ) + + ringtone = service_data[ATTR_RINGTONE] + try: + await chime.play(ChimeToneEnum[ringtone].value) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err + + hass.services.async_register( + DOMAIN, + "play_chime", + async_play_chime, + schema=vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): list[str], + vol.Required(ATTR_RINGTONE): vol.In( + [method.name for method in ChimeToneEnum][1:] + ), + } + ), + ) diff --git a/homeassistant/components/reolink/services.yaml b/homeassistant/components/reolink/services.yaml index 42b9af34eb0..fe7fba9cdc7 100644 --- a/homeassistant/components/reolink/services.yaml +++ b/homeassistant/components/reolink/services.yaml @@ -16,3 +16,30 @@ ptz_move: min: 1 max: 64 step: 1 + +play_chime: + fields: + device_id: + required: true + selector: + device: + multiple: true + filter: + integration: reolink + model: "Reolink Chime" + ringtone: + required: true + selector: + select: + translation_key: ringtone + options: + - citybird + - originaltune + - pianokey + - loop + - attraction + - hophop + - goodday + - operetta + - moonlight + - waybackhome diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index cad09f71562..3710c3743fa 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -50,6 +50,14 @@ } } }, + "exceptions": { + "service_entry_ex": { + "message": "Reolink {service_name} error: config entry not found or not loaded" + }, + "service_not_chime": { + "message": "Reolink play_chime error: {device_name} is not a chime" + } + }, "issues": { "https_webhook": { "title": "Reolink webhook URL uses HTTPS (SSL)", @@ -86,6 +94,36 @@ "description": "PTZ move speed." } } + }, + "play_chime": { + "name": "Play chime", + "description": "Play a ringtone on a chime.", + "fields": { + "device_id": { + "name": "Target chime", + "description": "The chime to play the ringtone on." + }, + "ringtone": { + "name": "Ringtone", + "description": "Ringtone to play." + } + } + } + }, + "selector": { + "ringtone": { + "options": { + "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%]" + } } }, "entity": { diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index cf4659224e3..305579e35cb 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -2,11 +2,24 @@ from __future__ import annotations +from dataclasses import dataclass + from homeassistant import config_entries from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import ReolinkData from .const import DOMAIN +from .host import ReolinkHost + + +@dataclass +class ReolinkData: + """Data for the Reolink integration.""" + + host: ReolinkHost + device_coordinator: DataUpdateCoordinator[None] + firmware_coordinator: DataUpdateCoordinator[None] def is_connected(hass: HomeAssistant, config_entry: config_entries.ConfigEntry) -> bool: @@ -19,3 +32,25 @@ def is_connected(hass: HomeAssistant, config_entry: config_entries.ConfigEntry) and config_entry.state == config_entries.ConfigEntryState.LOADED and reolink_data.device_coordinator.last_update_success ) + + +def get_device_uid_and_ch( + device: dr.DeviceEntry, host: ReolinkHost +) -> 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, is_chime) diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 082a543e392..0534f36f4c5 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -118,6 +118,7 @@ async def test_chime_select( entity_id = f"{Platform.SELECT}.test_chime_visitor_ringtone" assert hass.states.get(entity_id).state == "pianokey" + # Test selecting chime ringtone option test_chime.set_tone = AsyncMock() await hass.services.async_call( SELECT_DOMAIN, @@ -145,6 +146,7 @@ async def test_chime_select( blocking=True, ) + # Test unavailable test_chime.event_info = {} freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/reolink/test_services.py b/tests/components/reolink/test_services.py new file mode 100644 index 00000000000..a4b7d8f0da4 --- /dev/null +++ b/tests/components/reolink/test_services.py @@ -0,0 +1,116 @@ +"""Test the Reolink services.""" + +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.const import DOMAIN as REOLINK_DOMAIN +from homeassistant.components.reolink.services import ATTR_RINGTONE +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_play_chime_service_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + test_chime: Chime, + entity_registry: er.EntityRegistry, +) -> None: + """Test chime play service.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SELECT}.test_chime_visitor_ringtone" + entity = entity_registry.async_get(entity_id) + assert entity is not None + device_id = entity.device_id + + # Test chime play service with device + test_chime.play = AsyncMock() + await hass.services.async_call( + REOLINK_DOMAIN, + "play_chime", + {ATTR_DEVICE_ID: [device_id], ATTR_RINGTONE: "attraction"}, + blocking=True, + ) + test_chime.play.assert_called_once() + + # Test errors + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + REOLINK_DOMAIN, + "play_chime", + {ATTR_DEVICE_ID: ["invalid_id"], ATTR_RINGTONE: "attraction"}, + blocking=True, + ) + + test_chime.play = AsyncMock(side_effect=ReolinkError("Test error")) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + REOLINK_DOMAIN, + "play_chime", + {ATTR_DEVICE_ID: [device_id], ATTR_RINGTONE: "attraction"}, + blocking=True, + ) + + test_chime.play = AsyncMock(side_effect=InvalidParameterError("Test error")) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + REOLINK_DOMAIN, + "play_chime", + {ATTR_DEVICE_ID: [device_id], ATTR_RINGTONE: "attraction"}, + blocking=True, + ) + + reolink_connect.chime.return_value = None + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + REOLINK_DOMAIN, + "play_chime", + {ATTR_DEVICE_ID: [device_id], ATTR_RINGTONE: "attraction"}, + blocking=True, + ) + + +async def test_play_chime_service_unloaded( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + test_chime: Chime, + entity_registry: er.EntityRegistry, +) -> None: + """Test chime play service when config entry is unloaded.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SELECT}.test_chime_visitor_ringtone" + entity = entity_registry.async_get(entity_id) + assert entity is not None + device_id = entity.device_id + + # Unload the config entry + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.NOT_LOADED + + # Test chime play service + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + REOLINK_DOMAIN, + "play_chime", + {ATTR_DEVICE_ID: [device_id], ATTR_RINGTONE: "attraction"}, + blocking=True, + ) -- GitLab