Add UniFi Protect select platform (#63337)

This commit is contained in:
Christopher Bailey 2022-01-03 18:42:10 -05:00 committed by GitHub
parent 48057e1dfb
commit a2677983a2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1284 additions and 13 deletions

View file

@ -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,
}
)

View file

@ -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()

View file

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

View file

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

View file

@ -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

View file

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

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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,
}

View file

@ -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),
)

View file

@ -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,
}