"""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 )