From a2677983a2924366ea13eab416bf286996a64bdb Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Mon, 3 Jan 2022 18:42:10 -0500 Subject: [PATCH] Add UniFi Protect select platform (#63337) --- .../components/unifiprotect/const.py | 19 +- homeassistant/components/unifiprotect/data.py | 18 + .../components/unifiprotect/entity.py | 6 +- .../components/unifiprotect/select.py | 351 +++++++++ .../components/unifiprotect/services.yaml | 34 + tests/components/unifiprotect/conftest.py | 60 +- .../sample_data/sample_liveview.json | 72 ++ .../sample_data/sample_viewport.json | 35 + tests/components/unifiprotect/test_camera.py | 3 + tests/components/unifiprotect/test_light.py | 1 + .../unifiprotect/test_media_player.py | 2 + tests/components/unifiprotect/test_number.py | 8 +- tests/components/unifiprotect/test_select.py | 680 ++++++++++++++++++ tests/components/unifiprotect/test_switch.py | 8 +- 14 files changed, 1284 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/unifiprotect/select.py create mode 100644 homeassistant/components/unifiprotect/services.yaml create mode 100644 tests/components/unifiprotect/sample_data/sample_liveview.json create mode 100644 tests/components/unifiprotect/sample_data/sample_viewport.json create mode 100644 tests/components/unifiprotect/test_select.py diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 4dc712ad36c..aa960d2696f 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -1,8 +1,10 @@ """Constant definitions for UniFi Protect Integration.""" from pyunifiprotect.data.types import ModelType, Version +import voluptuous as vol -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.helpers import config_validation as cv DOMAIN = "unifiprotect" @@ -11,6 +13,8 @@ ATTR_HEIGHT = "height" ATTR_FPS = "fps" ATTR_BITRATE = "bitrate" ATTR_CHANNEL_ID = "channel_id" +ATTR_MESSAGE = "message" +ATTR_DURATION = "duration" CONF_DISABLE_RTSP = "disable_rtsp" CONF_ALL_UPDATES = "all_updates" @@ -41,11 +45,24 @@ DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT} MIN_REQUIRED_PROTECT_V = Version("1.20.0") OUTDATED_LOG_MESSAGE = "You are running v%s of UniFi Protect. Minimum required version is v%s. Please upgrade UniFi Protect and then retry" +SERVICE_SET_DOORBELL_MESSAGE = "set_doorbell_message" + +TYPE_EMPTY_VALUE = "" + PLATFORMS = [ Platform.BUTTON, Platform.CAMERA, Platform.LIGHT, Platform.MEDIA_PLAYER, Platform.NUMBER, + Platform.SELECT, Platform.SWITCH, ] + +SET_DOORBELL_LCD_MESSAGE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_DURATION, default=""): cv.string, + } +) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 6f6e355ec35..0057af8db8c 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -10,6 +10,7 @@ from typing import Any from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient from pyunifiprotect.data import Bootstrap, ModelType, WSSubscriptionMessage from pyunifiprotect.data.base import ProtectAdoptableDeviceModel, ProtectDeviceModel +from pyunifiprotect.data.nvr import Liveview from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -107,6 +108,22 @@ class ProtectData: def _async_process_ws_message(self, message: WSSubscriptionMessage) -> None: if message.new_obj.model in DEVICES_WITH_ENTITIES: self.async_signal_device_id_update(message.new_obj.id) + # trigger update for all Cameras with LCD screens when NVR Doorbell settings updates + if "doorbell_settings" in message.changed_data: + _LOGGER.debug( + "Doorbell messages updated. Updating devices with LCD screens" + ) + self.api.bootstrap.nvr.update_all_messages() + for camera in self.api.bootstrap.cameras.values(): + if camera.feature_flags.has_lcd_screen: + self.async_signal_device_id_update(camera.id) + # alert user viewport needs restart so voice clients can get new options + elif len(self.api.bootstrap.viewers) > 0 and isinstance( + message.new_obj, Liveview + ): + _LOGGER.warning( + "Liveviews updated. Restart Home Assistant to update Viewport select options" + ) @callback def _async_process_updates(self, updates: Bootstrap | None) -> None: @@ -157,5 +174,6 @@ class ProtectData: if not self._subscriptions.get(device_id): return + _LOGGER.debug("Updating device: %s", device_id) for update_callback in self._subscriptions[device_id]: update_callback() diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 43f3f2b8541..44e5846a51e 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -70,7 +70,7 @@ def async_all_device_entities( camera_descs: Sequence[ProtectRequiredKeysMixin] | None = None, light_descs: Sequence[ProtectRequiredKeysMixin] | None = None, sense_descs: Sequence[ProtectRequiredKeysMixin] | None = None, - viewport_descs: Sequence[ProtectRequiredKeysMixin] | None = None, + viewer_descs: Sequence[ProtectRequiredKeysMixin] | None = None, all_descs: Sequence[ProtectRequiredKeysMixin] | None = None, ) -> list[ProtectDeviceEntity]: """Generate a list of all the device entities.""" @@ -78,13 +78,13 @@ def async_all_device_entities( camera_descs = list(camera_descs or []) + all_descs light_descs = list(light_descs or []) + all_descs sense_descs = list(sense_descs or []) + all_descs - viewport_descs = list(viewport_descs or []) + all_descs + viewer_descs = list(viewer_descs or []) + all_descs return ( _async_device_entities(data, klass, ModelType.CAMERA, camera_descs) + _async_device_entities(data, klass, ModelType.LIGHT, light_descs) + _async_device_entities(data, klass, ModelType.SENSOR, sense_descs) - + _async_device_entities(data, klass, ModelType.VIEWPORT, viewport_descs) + + _async_device_entities(data, klass, ModelType.VIEWPORT, viewer_descs) ) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py new file mode 100644 index 00000000000..e3e93734e67 --- /dev/null +++ b/homeassistant/components/unifiprotect/select.py @@ -0,0 +1,351 @@ +"""This component provides select entities for UniFi Protect.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from enum import Enum +import logging +from typing import Any, Final + +from pyunifiprotect.data import ( + Camera, + DoorbellMessageType, + IRLEDMode, + Light, + LightModeEnableType, + LightModeType, + Liveview, + RecordingMode, + Viewer, +) +from pyunifiprotect.data.devices import LCDMessage + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity import EntityCategory +from homeassistant.util.dt import utcnow + +from .const import ( + DOMAIN, + SERVICE_SET_DOORBELL_MESSAGE, + SET_DOORBELL_LCD_MESSAGE_SCHEMA, + TYPE_EMPTY_VALUE, +) +from .data import ProtectData +from .entity import ProtectDeviceEntity, async_all_device_entities +from .models import ProtectRequiredKeysMixin +from .utils import get_nested_attr + +_LOGGER = logging.getLogger(__name__) + +_KEY_IR = "infrared" +_KEY_REC_MODE = "recording_mode" +_KEY_VIEWER = "viewer" +_KEY_LIGHT_MOTION = "light_motion" +_KEY_DOORBELL_TEXT = "doorbell_text" +_KEY_PAIRED_CAMERA = "paired_camera" + +INFRARED_MODES = [ + {"id": IRLEDMode.AUTO.value, "name": "Auto"}, + {"id": IRLEDMode.ON.value, "name": "Always Enable"}, + {"id": IRLEDMode.AUTO_NO_LED.value, "name": "Auto (Filter Only, no LED's)"}, + {"id": IRLEDMode.OFF.value, "name": "Always Disable"}, +] + + +LIGHT_MODE_MOTION = "On Motion - Always" +LIGHT_MODE_MOTION_DARK = "On Motion - When Dark" +LIGHT_MODE_DARK = "When Dark" +LIGHT_MODE_OFF = "Manual" +LIGHT_MODES = [LIGHT_MODE_MOTION, LIGHT_MODE_DARK, LIGHT_MODE_OFF] + +LIGHT_MODE_TO_SETTINGS = { + LIGHT_MODE_MOTION: (LightModeType.MOTION.value, LightModeEnableType.ALWAYS.value), + LIGHT_MODE_MOTION_DARK: ( + LightModeType.MOTION.value, + LightModeEnableType.DARK.value, + ), + LIGHT_MODE_DARK: (LightModeType.WHEN_DARK.value, LightModeEnableType.DARK.value), + LIGHT_MODE_OFF: (LightModeType.MANUAL.value, None), +} + +MOTION_MODE_TO_LIGHT_MODE = [ + {"id": LightModeType.MOTION.value, "name": LIGHT_MODE_MOTION}, + {"id": f"{LightModeType.MOTION.value}Dark", "name": LIGHT_MODE_MOTION_DARK}, + {"id": LightModeType.WHEN_DARK.value, "name": LIGHT_MODE_DARK}, + {"id": LightModeType.MANUAL.value, "name": LIGHT_MODE_OFF}, +] + +DEVICE_RECORDING_MODES = [ + {"id": mode.value, "name": mode.value.title()} for mode in list(RecordingMode) +] + +DEVICE_CLASS_LCD_MESSAGE: Final = "unifiprotect__lcd_message" + + +@dataclass +class ProtectSelectEntityDescription(ProtectRequiredKeysMixin, SelectEntityDescription): + """Describes UniFi Protect Select entity.""" + + ufp_options: list[dict[str, Any]] | None = None + ufp_enum_type: type[Enum] | None = None + ufp_set_function: str | None = None + + +CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( + ProtectSelectEntityDescription( + key=_KEY_REC_MODE, + name="Recording Mode", + icon="mdi:video-outline", + entity_category=EntityCategory.CONFIG, + ufp_options=DEVICE_RECORDING_MODES, + ufp_enum_type=RecordingMode, + ufp_value="recording_settings.mode", + ufp_set_function="set_recording_mode", + ), + ProtectSelectEntityDescription( + key=_KEY_IR, + name="Infrared Mode", + icon="mdi:circle-opacity", + entity_category=EntityCategory.CONFIG, + ufp_required_field="feature_flags.has_led_ir", + ufp_options=INFRARED_MODES, + ufp_enum_type=IRLEDMode, + ufp_value="isp_settings.ir_led_mode", + ufp_set_function="set_ir_led_model", + ), + ProtectSelectEntityDescription( + key=_KEY_DOORBELL_TEXT, + name="Doorbell Text", + icon="mdi:card-text", + entity_category=EntityCategory.CONFIG, + device_class=DEVICE_CLASS_LCD_MESSAGE, + ufp_required_field="feature_flags.has_lcd_screen", + ufp_value="lcd_message", + ), +) + +LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( + ProtectSelectEntityDescription( + key=_KEY_LIGHT_MOTION, + name="Light Mode", + icon="mdi:spotlight", + entity_category=EntityCategory.CONFIG, + ufp_options=MOTION_MODE_TO_LIGHT_MODE, + ufp_value="light_mode_settings.mode", + ), + ProtectSelectEntityDescription( + key=_KEY_PAIRED_CAMERA, + name="Paired Camera", + icon="mdi:cctv", + entity_category=EntityCategory.CONFIG, + ufp_value="camera_id", + ), +) + +VIEWER_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( + ProtectSelectEntityDescription( + key=_KEY_VIEWER, + name="Liveview", + icon="mdi:view-dashboard", + entity_category=None, + ufp_value="liveview", + ufp_set_function="set_liveview", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up number entities for UniFi Protect integration.""" + data: ProtectData = hass.data[DOMAIN][entry.entry_id] + entities: list[ProtectDeviceEntity] = async_all_device_entities( + data, + ProtectSelects, + camera_descs=CAMERA_SELECTS, + light_descs=LIGHT_SELECTS, + viewer_descs=VIEWER_SELECTS, + ) + + async_add_entities(entities) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_SET_DOORBELL_MESSAGE, + SET_DOORBELL_LCD_MESSAGE_SCHEMA, + "async_set_doorbell_message", + ) + + +class ProtectSelects(ProtectDeviceEntity, SelectEntity): + """A UniFi Protect Select Entity.""" + + def __init__( + self, + data: ProtectData, + device: Camera | Light | Viewer, + description: ProtectSelectEntityDescription, + ) -> None: + """Initialize the unifi protect select entity.""" + assert description.ufp_value is not None + + self.device: Camera | Light | Viewer = device + self.entity_description: ProtectSelectEntityDescription = description + super().__init__(data) + self._attr_name = f"{self.device.name} {self.entity_description.name}" + + options = description.ufp_options + if options is not None: + self._attr_options = [item["name"] for item in options] + self._hass_to_unifi_options: dict[str, Any] = { + item["name"]: item["id"] for item in options + } + self._unifi_to_hass_options: dict[Any, str] = { + item["id"]: item["name"] for item in options + } + self._async_set_dynamic_options() + + @callback + def _async_update_device_from_protect(self) -> None: + super()._async_update_device_from_protect() + + # entities with categories are not exposed for voice and safe to update dynamically + if self.entity_description.entity_category is not None: + _LOGGER.debug( + "Updating dynamic select options for %s", self.entity_description.name + ) + self._async_set_dynamic_options() + + @callback + def _async_set_dynamic_options(self) -> None: + """Options that do not actually update dynamically. + + This is due to possible downstream platforms dependencies on these options. + """ + if self.entity_description.ufp_options is not None: + return + + if self.entity_description.key == _KEY_VIEWER: + options = [ + {"id": item.id, "name": item.name} + for item in self.data.api.bootstrap.liveviews.values() + ] + elif self.entity_description.key == _KEY_DOORBELL_TEXT: + default_message = ( + self.data.api.bootstrap.nvr.doorbell_settings.default_message_text + ) + messages = self.data.api.bootstrap.nvr.doorbell_settings.all_messages + built_messages = ( + {"id": item.type.value, "name": item.text} for item in messages + ) + + options = [ + {"id": "", "name": f"Default Message ({default_message})"}, + *built_messages, + ] + elif self.entity_description.key == _KEY_PAIRED_CAMERA: + options = [{"id": TYPE_EMPTY_VALUE, "name": "Not Paired"}] + for camera in self.data.api.bootstrap.cameras.values(): + options.append({"id": camera.id, "name": camera.name}) + + self._attr_options = [item["name"] for item in options] + self._hass_to_unifi_options = {item["name"]: item["id"] for item in options} + self._unifi_to_hass_options = {item["id"]: item["name"] for item in options} + + @property + def current_option(self) -> str: + """Return the current selected option.""" + + assert self.entity_description.ufp_value is not None + unifi_value = get_nested_attr(self.device, self.entity_description.ufp_value) + + if unifi_value is None: + unifi_value = TYPE_EMPTY_VALUE + elif isinstance(unifi_value, Liveview): + unifi_value = unifi_value.id + elif self.entity_description.key == _KEY_LIGHT_MOTION: + assert isinstance(self.device, Light) + + # a bit of extra to allow On Motion Always/Dark + if ( + self.device.light_mode_settings.mode == LightModeType.MOTION + and self.device.light_mode_settings.enable_at + == LightModeEnableType.DARK + ): + unifi_value = f"{LightModeType.MOTION.value}Dark" + elif self.entity_description.key == _KEY_DOORBELL_TEXT: + assert isinstance(unifi_value, LCDMessage) + return unifi_value.text + return self._unifi_to_hass_options.get(unifi_value, unifi_value) + + async def async_select_option(self, option: str) -> None: + """Change the Select Entity Option.""" + + if isinstance(self.device, Light): + if self.entity_description.key == _KEY_LIGHT_MOTION: + lightmode, timing = LIGHT_MODE_TO_SETTINGS[option] + _LOGGER.debug("Changing Light Mode to %s", option) + await self.device.set_light_settings( + LightModeType(lightmode), + enable_at=None if timing is None else LightModeEnableType(timing), + ) + return + + unifi_value = self._hass_to_unifi_options[option] + if self.entity_description.key == _KEY_PAIRED_CAMERA: + if unifi_value == TYPE_EMPTY_VALUE: + unifi_value = None + camera = self.data.api.bootstrap.cameras.get(unifi_value) + await self.device.set_paired_camera(camera) + _LOGGER.debug("Changed Paired Camera to to: %s", option) + return + + unifi_value = self._hass_to_unifi_options[option] + if isinstance(self.device, Camera): + if self.entity_description.key == _KEY_DOORBELL_TEXT: + if unifi_value.startswith(DoorbellMessageType.CUSTOM_MESSAGE.value): + await self.device.set_lcd_text( + DoorbellMessageType.CUSTOM_MESSAGE, text=option + ) + elif unifi_value == TYPE_EMPTY_VALUE: + await self.device.set_lcd_text(None) + else: + await self.device.set_lcd_text(DoorbellMessageType(unifi_value)) + + _LOGGER.debug("Changed Doorbell LCD Text to: %s", option) + return + + if self.entity_description.ufp_enum_type is not None: + unifi_value = self.entity_description.ufp_enum_type(unifi_value) + elif self.entity_description.key == _KEY_VIEWER: + unifi_value = self.data.api.bootstrap.liveviews[unifi_value] + + _LOGGER.debug("%s set to: %s", self.entity_description.key, option) + assert self.entity_description.ufp_set_function + coro = getattr(self.device, self.entity_description.ufp_set_function) + await coro(unifi_value) + + async def async_set_doorbell_message(self, message: str, duration: str) -> None: + """Set LCD Message on Doorbell display.""" + + if self.entity_description.key != _KEY_DOORBELL_TEXT: + raise HomeAssistantError("Not a doorbell text select entity") + + assert isinstance(self.device, Camera) + reset_at = None + timeout_msg = "" + if duration.isnumeric(): + reset_at = utcnow() + timedelta(minutes=int(duration)) + timeout_msg = f" with timeout of {duration} minute(s)" + + _LOGGER.debug( + 'Setting message for %s to "%s"%s', self.device.name, message, timeout_msg + ) + await self.device.set_lcd_text( + DoorbellMessageType.CUSTOM_MESSAGE, message, reset_at=reset_at + ) diff --git a/homeassistant/components/unifiprotect/services.yaml b/homeassistant/components/unifiprotect/services.yaml new file mode 100644 index 00000000000..714da9aa3d8 --- /dev/null +++ b/homeassistant/components/unifiprotect/services.yaml @@ -0,0 +1,34 @@ +set_doorbell_message: + name: Set Doorbell message + description: > + Use to dynamically set the message on a Doorbell LCD screen. Should only be used to set dynamic messages + (i.e. setting the current outdoor temperature on your Doorbell). Static messages should still using the Select entity and the + add_doorbell_text / remove_doorbell_text services. + fields: + entity_id: + name: Doorbell Text + description: (Required) Doorbell to display message on + example: "select.front_doorbell_camera_doorbell_text" + required: true + selector: + entity: + integration: unifiprotect + domain: select + device_class: unifiprotect__lcd_message + message: + name: Message to display + description: (Required) Message to display on LCD Panel. Max 30 characters + example: "Welcome | 09:23 | 25°C" + required: true + selector: + text: + duration: + name: Duration (minutes) + description: "(Optional) Number of minutes to display message, before returning to default. Leave blank to display always" + example: 5 + selector: + number: + min: 1 + max: 120 + step: 1 + mode: slider diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index d604453606f..c1e5cc1a8d0 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -12,6 +12,9 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from pyunifiprotect.data import Camera, Light, Version, WSSubscriptionMessage from pyunifiprotect.data.base import ProtectAdoptableDeviceModel +from pyunifiprotect.data.devices import Viewer +from pyunifiprotect.data.nvr import DoorbellMessage, Liveview +from pyunifiprotect.data.types import DoorbellMessageType, ModelType from homeassistant.components.unifiprotect.const import DOMAIN, MIN_REQUIRED_PROTECT_V from homeassistant.const import Platform @@ -33,6 +36,27 @@ class MockPortData: rtsps: int = 7447 +@dataclass +class MockDoorbellSettings: + """Mock Port information.""" + + default_message_text = "Welcome" + all_messages = [ + DoorbellMessage( + type=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR, + text=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value.replace("_", " "), + ), + DoorbellMessage( + type=DoorbellMessageType.DO_NOT_DISTURB, + text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "), + ), + DoorbellMessage( + type=DoorbellMessageType.CUSTOM_MESSAGE, + text="Test", + ), + ] + + @dataclass class MockNvrData: """Mock for NVR.""" @@ -42,6 +66,9 @@ class MockNvrData: name: str id: str ports: MockPortData = MockPortData() + doorbell_settings = MockDoorbellSettings() + update_all_messages = Mock() + model: ModelType = ModelType.NVR @dataclass @@ -53,6 +80,15 @@ class MockBootstrap: lights: dict[str, Any] sensors: dict[str, Any] viewers: dict[str, Any] + liveviews: dict[str, Any] + + def reset_objects(self) -> None: + """Reset all devices on bootstrap for tests.""" + self.cameras = {} + self.lights = {} + self.sensors = {} + self.viewers = {} + self.liveviews = {} @dataclass @@ -71,7 +107,7 @@ MOCK_OLD_NVR_DATA = MockNvrData( ) MOCK_BOOTSTRAP = MockBootstrap( - nvr=MOCK_NVR_DATA, cameras={}, lights={}, sensors={}, viewers={} + nvr=MOCK_NVR_DATA, cameras={}, lights={}, sensors={}, viewers={}, liveviews={} ) @@ -122,6 +158,17 @@ def mock_entry( yield MockEntityFixture(mock_config, mock_client) +@pytest.fixture +def mock_liveview(): + """Mock UniFi Protect Camera device.""" + + path = Path(__file__).parent / "sample_data" / "sample_liveview.json" + with open(path, encoding="utf-8") as json_file: + data = json.load(json_file) + + yield Liveview.from_unifi_dict(**data) + + @pytest.fixture def mock_camera(): """Mock UniFi Protect Camera device.""" @@ -144,6 +191,17 @@ def mock_light(): yield Light.from_unifi_dict(**data) +@pytest.fixture +def mock_viewer(): + """Mock UniFi Protect Viewport device.""" + + path = Path(__file__).parent / "sample_data" / "sample_viewport.json" + with open(path, encoding="utf-8") as json_file: + data = json.load(json_file) + + yield Viewer.from_unifi_dict(**data) + + async def time_changed(hass: HomeAssistant, seconds: int) -> None: """Trigger time changed.""" next_update = dt_util.utcnow() + timedelta(seconds) diff --git a/tests/components/unifiprotect/sample_data/sample_liveview.json b/tests/components/unifiprotect/sample_data/sample_liveview.json new file mode 100644 index 00000000000..70e641285bb --- /dev/null +++ b/tests/components/unifiprotect/sample_data/sample_liveview.json @@ -0,0 +1,72 @@ +{ + "name": "Default", + "isDefault": true, + "isGlobal": true, + "layout": 9, + "slots": [ + { + "cameras": [ + "0488c1538efb5cc9f73f77ca" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [ + "0de062b4f6922d489d3b312d" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [ + "193be66559c03ec5629f54cd" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [ + "16b0c551e36d872806f2806b" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [ + "5becd64d90f1cae3a4146a0f" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [ + "4f5fab885aca3f7c226b22b9" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [ + "cc7a572a0a8677baae933873" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [ + "f4e9f4421209908c51284e67" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [], + "cycleMode": "time", + "cycleInterval": 10 + } + ], + "owner": "5a839670ad0a929bf8271c26", + "id": "ecb21f15e6d8fae65fea82f8", + "modelKey": "liveview" +} diff --git a/tests/components/unifiprotect/sample_data/sample_viewport.json b/tests/components/unifiprotect/sample_data/sample_viewport.json new file mode 100644 index 00000000000..001abd86417 --- /dev/null +++ b/tests/components/unifiprotect/sample_data/sample_viewport.json @@ -0,0 +1,35 @@ +{ + "mac": "4EDC1B6D2F76", + "host": "192.168.34.145", + "connectionHost": "192.168.178.217", + "type": "UP Viewport", + "name": "Yfptv Ttklkw", + "upSince": 1639845760126, + "uptime": 178121, + "lastSeen": 1640023881126, + "connectedSince": 1640020660049, + "state": "CONNECTED", + "hardwareRevision": null, + "firmwareVersion": "1.2.54", + "latestFirmwareVersion": "1.2.54", + "firmwareBuild": "dcfb16f3.210907.625", + "isUpdating": false, + "isAdopting": false, + "isAdopted": true, + "isAdoptedByOther": false, + "isProvisioned": false, + "isRebooting": false, + "isSshEnabled": false, + "canAdopt": false, + "isAttemptingToConnect": false, + "streamLimit": 16, + "softwareVersion": "1.2.54", + "wiredConnectionState": { + "phyRate": 1000 + }, + "liveview": "ecb21f15e6d8fae65fea82f8", + "id": "5ec2a22846047eeb6e976922", + "isConnected": true, + "marketName": "UP ViewPort", + "modelKey": "viewer" +} diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index c202a345f32..68ea23add0d 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -436,6 +436,7 @@ async def test_camera_ws_update( new_camera.is_recording = True mock_msg = Mock() + mock_msg.changed_data = {} mock_msg.new_obj = new_camera new_bootstrap.cameras = {new_camera.id: new_camera} @@ -463,6 +464,7 @@ async def test_camera_ws_update_offline( new_camera.state = StateType.DISCONNECTED mock_msg = Mock() + mock_msg.changed_data = {} mock_msg.new_obj = new_camera new_bootstrap.cameras = {new_camera.id: new_camera} @@ -477,6 +479,7 @@ async def test_camera_ws_update_offline( new_camera.state = StateType.CONNECTED mock_msg = Mock() + mock_msg.changed_data = {} mock_msg.new_obj = new_camera new_bootstrap.cameras = {new_camera.id: new_camera} diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index 5e03e20d494..8f4dc4f8fcf 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -84,6 +84,7 @@ async def test_light_update( new_light.light_device_settings.led_level = 3 mock_msg = Mock() + mock_msg.changed_data = {} mock_msg.new_obj = new_light new_bootstrap.lights = {new_light.id: new_light} diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py index 5c65d285f76..4d83da3fabb 100644 --- a/tests/components/unifiprotect/test_media_player.py +++ b/tests/components/unifiprotect/test_media_player.py @@ -98,6 +98,7 @@ async def test_media_player_update( new_camera.talkback_stream.is_running = True mock_msg = Mock() + mock_msg.changed_data = {} mock_msg.new_obj = new_camera new_bootstrap.cameras = {new_camera.id: new_camera} @@ -142,6 +143,7 @@ async def test_media_player_stop( new_camera.talkback_stream.is_running = True mock_msg = Mock() + mock_msg.changed_data = {} mock_msg.new_obj = new_camera new_bootstrap.cameras = {new_camera.id: new_camera} diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index c5c1869b0e4..9346977df92 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -41,7 +41,7 @@ async def light_fixture( light_obj.light_device_settings.pir_sensitivity = 45 light_obj.light_device_settings.pir_duration = timedelta(seconds=45) - mock_entry.api.bootstrap.cameras = {} + mock_entry.api.bootstrap.reset_objects() mock_entry.api.bootstrap.lights = { light_obj.id: light_obj, } @@ -81,7 +81,7 @@ async def camera_fixture( camera_obj.isp_settings.zoom_position = 0 camera_obj.chime_duration = timedelta(seconds=0) - mock_entry.api.bootstrap.lights = {} + mock_entry.api.bootstrap.reset_objects() mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, } @@ -159,7 +159,7 @@ async def test_number_setup_camera_none( # has_wdr is an the inverse of has HDR camera_obj.feature_flags.has_hdr = True - mock_entry.api.bootstrap.lights = {} + mock_entry.api.bootstrap.reset_objects() mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, } @@ -188,7 +188,7 @@ async def test_number_setup_camera_missing_attr( Camera.__config__.validate_assignment = True - mock_entry.api.bootstrap.lights = {} + mock_entry.api.bootstrap.reset_objects() mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, } diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py new file mode 100644 index 00000000000..798aaa56169 --- /dev/null +++ b/tests/components/unifiprotect/test_select.py @@ -0,0 +1,680 @@ +"""Test the UniFi Protect number platform.""" +# pylint: disable=protected-access +from __future__ import annotations + +from copy import copy +from datetime import timedelta +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from pyunifiprotect.data import Camera, Light +from pyunifiprotect.data.devices import LCDMessage, Viewer +from pyunifiprotect.data.nvr import DoorbellMessage, Liveview +from pyunifiprotect.data.types import ( + DoorbellMessageType, + IRLEDMode, + LightModeEnableType, + LightModeType, + RecordingMode, +) + +from homeassistant.components.select.const import ATTR_OPTIONS +from homeassistant.components.unifiprotect.const import ( + ATTR_DURATION, + ATTR_MESSAGE, + DEFAULT_ATTRIBUTION, + SERVICE_SET_DOORBELL_MESSAGE, +) +from homeassistant.components.unifiprotect.select import ( + CAMERA_SELECTS, + LIGHT_MODE_OFF, + LIGHT_SELECTS, + VIEWER_SELECTS, +) +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, ATTR_OPTION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.util.dt import utcnow + +from .conftest import ( + MockEntityFixture, + assert_entity_counts, + ids_from_device_description, +) + + +@pytest.fixture(name="viewer") +async def viewer_fixture( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_viewer: Viewer, + mock_liveview: Liveview, +): + """Fixture for a single viewport for testing the number platform.""" + + # disable pydantic validation so mocking can happen + Viewer.__config__.validate_assignment = False + + viewer_obj = mock_viewer.copy(deep=True) + viewer_obj._api = mock_entry.api + viewer_obj.name = "Test Viewer" + viewer_obj.liveview_id = mock_liveview.id + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.viewers = { + viewer_obj.id: viewer_obj, + } + mock_entry.api.bootstrap.liveviews = {mock_liveview.id: mock_liveview} + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SELECT, 1, 1) + + yield viewer_obj + + Viewer.__config__.validate_assignment = True + + +@pytest.fixture(name="camera") +async def camera_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Fixture for a single camera for testing the switch platform.""" + + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.feature_flags.has_lcd_screen = True + camera_obj.recording_settings.mode = RecordingMode.ALWAYS + camera_obj.isp_settings.ir_led_mode = IRLEDMode.AUTO + camera_obj.lcd_message = None + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SELECT, 3, 3) + + yield camera_obj + + Camera.__config__.validate_assignment = True + + +@pytest.fixture(name="light") +async def light_fixture( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_light: Light, + camera: Camera, +): + """Fixture for a single light for testing the number platform.""" + + # disable pydantic validation so mocking can happen + Light.__config__.validate_assignment = False + + light_obj = mock_light.copy(deep=True) + light_obj._api = mock_entry.api + light_obj.name = "Test Light" + light_obj.camera_id = None + light_obj.light_mode_settings.mode = LightModeType.MOTION + light_obj.light_mode_settings.enable_at = LightModeEnableType.DARK + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.cameras = {camera.id: camera} + mock_entry.api.bootstrap.lights = { + light_obj.id: light_obj, + } + + await hass.config_entries.async_reload(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SELECT, 5, 5) + + yield light_obj + + Light.__config__.validate_assignment = True + + +@pytest.fixture(name="camera_none") +async def camera_none_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Fixture for a single camera for testing the switch platform.""" + + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.feature_flags.has_lcd_screen = False + camera_obj.recording_settings.mode = RecordingMode.ALWAYS + camera_obj.isp_settings.ir_led_mode = IRLEDMode.AUTO + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SELECT, 2, 2) + + yield camera_obj + + Camera.__config__.validate_assignment = True + + +async def test_select_setup_light( + hass: HomeAssistant, + light: Light, +): + """Test select entity setup for light devices.""" + + entity_registry = er.async_get(hass) + expected_values = ("On Motion - When Dark", "Not Paired") + + for index, description in enumerate(LIGHT_SELECTS): + unique_id, entity_id = ids_from_device_description( + Platform.SELECT, light, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_select_setup_viewer( + hass: HomeAssistant, + viewer: Viewer, +): + """Test select entity setup for light devices.""" + + entity_registry = er.async_get(hass) + description = VIEWER_SELECTS[0] + + unique_id, entity_id = ids_from_device_description( + Platform.SELECT, viewer, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == viewer.liveview.name + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_number_setup_camera_all( + hass: HomeAssistant, + camera: Camera, +): + """Test number entity setup for camera devices (all features).""" + + entity_registry = er.async_get(hass) + expected_values = ("Always", "Auto", "Default Message (Welcome)") + + for index, description in enumerate(CAMERA_SELECTS): + unique_id, entity_id = ids_from_device_description( + Platform.SELECT, camera, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_number_setup_camera_none( + hass: HomeAssistant, + camera_none: Camera, +): + """Test number entity setup for camera devices (no features).""" + + entity_registry = er.async_get(hass) + expected_values = ("Always", "Auto", "Default Message (Welcome)") + + for index, description in enumerate(CAMERA_SELECTS): + if index == 2: + return + + unique_id, entity_id = ids_from_device_description( + Platform.SELECT, camera_none, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_select_update_liveview( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + viewer: Viewer, + mock_liveview: Liveview, +): + """Test select entity update (new Liveview).""" + + _, entity_id = ids_from_device_description( + Platform.SELECT, viewer, VIEWER_SELECTS[0] + ) + + state = hass.states.get(entity_id) + assert state + expected_options = state.attributes[ATTR_OPTIONS] + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_liveview = copy(mock_liveview) + new_liveview.id = "test_id" + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_liveview + + new_bootstrap.liveviews = {**new_bootstrap.liveviews, new_liveview.id: new_liveview} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_OPTIONS] == expected_options + + +async def test_select_update_doorbell_settings( + hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera +): + """Test select entity update (new Doorbell Message).""" + + expected_length = ( + len(mock_entry.api.bootstrap.nvr.doorbell_settings.all_messages) + 1 + ) + + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[2] + ) + + state = hass.states.get(entity_id) + assert state + assert len(state.attributes[ATTR_OPTIONS]) == expected_length + + expected_length += 1 + new_nvr = copy(mock_entry.api.bootstrap.nvr) + new_nvr.doorbell_settings.all_messages = [ + *new_nvr.doorbell_settings.all_messages, + DoorbellMessage( + type=DoorbellMessageType.CUSTOM_MESSAGE, + text="Test2", + ), + ] + + mock_msg = Mock() + mock_msg.changed_data = {"doorbell_settings": {}} + mock_msg.new_obj = new_nvr + + mock_entry.api.bootstrap.nvr = new_nvr + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + new_nvr.update_all_messages.assert_called_once() + + state = hass.states.get(entity_id) + assert state + assert len(state.attributes[ATTR_OPTIONS]) == expected_length + + +async def test_select_update_doorbell_message( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: Camera, +): + """Test select entity update (change doorbell message).""" + + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[2] + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == "Default Message (Welcome)" + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_camera = camera.copy() + new_camera.lcd_message = LCDMessage( + type=DoorbellMessageType.CUSTOM_MESSAGE, text="Test" + ) + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + + new_bootstrap.cameras = {new_camera.id: new_camera} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "Test" + + +async def test_select_set_option_light_motion( + hass: HomeAssistant, + light: Light, +): + """Test Light Mode select.""" + _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[0]) + + light.__fields__["set_light_settings"] = Mock() + light.set_light_settings = AsyncMock() + + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: LIGHT_MODE_OFF}, + blocking=True, + ) + + light.set_light_settings.assert_called_once_with( + LightModeType.MANUAL, enable_at=None + ) + + +async def test_select_set_option_light_camera( + hass: HomeAssistant, + light: Light, +): + """Test Paired Camera select.""" + _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[1]) + + light.__fields__["set_paired_camera"] = Mock() + light.set_paired_camera = AsyncMock() + + camera = list(light.api.bootstrap.cameras.values())[0] + + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: camera.name}, + blocking=True, + ) + + light.set_paired_camera.assert_called_once_with(camera) + + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Not Paired"}, + blocking=True, + ) + + light.set_paired_camera.assert_called_with(None) + + +async def test_select_set_option_camera_recording( + hass: HomeAssistant, + camera: Camera, +): + """Test Recording Mode select.""" + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[0] + ) + + camera.__fields__["set_recording_mode"] = Mock() + camera.set_recording_mode = AsyncMock() + + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Never"}, + blocking=True, + ) + + camera.set_recording_mode.assert_called_once_with(RecordingMode.NEVER) + + +async def test_select_set_option_camera_ir( + hass: HomeAssistant, + camera: Camera, +): + """Test Infrared Mode select.""" + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[1] + ) + + camera.__fields__["set_ir_led_model"] = Mock() + camera.set_ir_led_model = AsyncMock() + + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Always Enable"}, + blocking=True, + ) + + camera.set_ir_led_model.assert_called_once_with(IRLEDMode.ON) + + +async def test_select_set_option_camera_doorbell_custom( + hass: HomeAssistant, + camera: Camera, +): + """Test Doorbell Text select (user defined message).""" + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[2] + ) + + camera.__fields__["set_lcd_text"] = Mock() + camera.set_lcd_text = AsyncMock() + + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Test"}, + blocking=True, + ) + + +async def test_select_set_option_camera_doorbell_unifi( + hass: HomeAssistant, + camera: Camera, +): + """Test Doorbell Text select (unifi message).""" + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[2] + ) + + camera.__fields__["set_lcd_text"] = Mock() + camera.set_lcd_text = AsyncMock() + + await hass.services.async_call( + "select", + "select_option", + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "LEAVE PACKAGE AT DOOR", + }, + blocking=True, + ) + + camera.set_lcd_text.assert_called_once_with( + DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR + ) + + await hass.services.async_call( + "select", + "select_option", + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "Default Message (Welcome)", + }, + blocking=True, + ) + + camera.set_lcd_text.assert_called_with(None) + + +async def test_select_set_option_camera_doorbell_default( + hass: HomeAssistant, + camera: Camera, +): + """Test Doorbell Text select (default message).""" + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[2] + ) + + camera.__fields__["set_lcd_text"] = Mock() + camera.set_lcd_text = AsyncMock() + + await hass.services.async_call( + "select", + "select_option", + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "Default Message (Welcome)", + }, + blocking=True, + ) + + camera.set_lcd_text.assert_called_once_with(None) + + +async def test_select_set_option_viewer( + hass: HomeAssistant, + viewer: Viewer, +): + """Test Liveview select.""" + _, entity_id = ids_from_device_description( + Platform.SELECT, viewer, VIEWER_SELECTS[0] + ) + + viewer.__fields__["set_liveview"] = Mock() + viewer.set_liveview = AsyncMock() + + liveview = list(viewer.api.bootstrap.liveviews.values())[0] + + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: liveview.name}, + blocking=True, + ) + + viewer.set_liveview.assert_called_once_with(liveview) + + +async def test_select_service_doorbell_invalid( + hass: HomeAssistant, + camera: Camera, +): + """Test Doorbell Text service (invalid).""" + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[1] + ) + + camera.__fields__["set_lcd_text"] = Mock() + camera.set_lcd_text = AsyncMock() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "unifiprotect", + SERVICE_SET_DOORBELL_MESSAGE, + {ATTR_ENTITY_ID: entity_id, ATTR_MESSAGE: "Test"}, + blocking=True, + ) + + camera.set_lcd_text.assert_not_called + + +async def test_select_service_doorbell_success( + hass: HomeAssistant, + camera: Camera, +): + """Test Doorbell Text service (success).""" + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[2] + ) + + camera.__fields__["set_lcd_text"] = Mock() + camera.set_lcd_text = AsyncMock() + + await hass.services.async_call( + "unifiprotect", + SERVICE_SET_DOORBELL_MESSAGE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MESSAGE: "Test", + }, + blocking=True, + ) + + camera.set_lcd_text.assert_called_once_with( + DoorbellMessageType.CUSTOM_MESSAGE, "Test", reset_at=None + ) + + +@patch("homeassistant.components.unifiprotect.select.utcnow") +async def test_select_service_doorbell_with_reset( + mock_now, + hass: HomeAssistant, + camera: Camera, +): + """Test Doorbell Text service (success with reset time).""" + now = utcnow() + mock_now.return_value = now + + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[2] + ) + + camera.__fields__["set_lcd_text"] = Mock() + camera.set_lcd_text = AsyncMock() + + await hass.services.async_call( + "unifiprotect", + SERVICE_SET_DOORBELL_MESSAGE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MESSAGE: "Test", + ATTR_DURATION: 60, + }, + blocking=True, + ) + + camera.set_lcd_text.assert_called_once_with( + DoorbellMessageType.CUSTOM_MESSAGE, + "Test", + reset_at=now + timedelta(minutes=60), + ) diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 1f819311c25..456169cff6a 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -42,7 +42,7 @@ async def light_fixture( light_obj.is_ssh_enabled = False light_obj.light_device_settings.is_indicator_enabled = False - mock_entry.api.bootstrap.cameras = {} + mock_entry.api.bootstrap.reset_objects() mock_entry.api.bootstrap.lights = { light_obj.id: light_obj, } @@ -91,7 +91,7 @@ async def camera_fixture( camera_obj.osd_settings.is_debug_enabled = False camera_obj.smart_detect_settings.object_types = [] - mock_entry.api.bootstrap.lights = {} + mock_entry.api.bootstrap.reset_objects() mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, } @@ -134,7 +134,7 @@ async def camera_none_fixture( camera_obj.osd_settings.is_logo_enabled = False camera_obj.osd_settings.is_debug_enabled = False - mock_entry.api.bootstrap.lights = {} + mock_entry.api.bootstrap.reset_objects() mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, } @@ -178,7 +178,7 @@ async def camera_privacy_fixture( camera_obj.osd_settings.is_logo_enabled = False camera_obj.osd_settings.is_debug_enabled = False - mock_entry.api.bootstrap.lights = {} + mock_entry.api.bootstrap.reset_objects() mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, }