From e5b7eac41139592d41c79f94b0271195ff3dbcae Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Sat, 1 Jan 2022 16:23:10 -0500 Subject: [PATCH] Add UniFi Protect switch platform (#63177) --- .../components/unifiprotect/const.py | 8 +- .../components/unifiprotect/entity.py | 79 ++- .../components/unifiprotect/models.py | 12 + .../components/unifiprotect/switch.py | 253 ++++++++++ .../components/unifiprotect/utils.py | 21 + tests/components/unifiprotect/conftest.py | 20 + tests/components/unifiprotect/test_button.py | 2 +- tests/components/unifiprotect/test_camera.py | 2 +- tests/components/unifiprotect/test_light.py | 2 +- .../unifiprotect/test_media_player.py | 2 +- tests/components/unifiprotect/test_switch.py | 474 ++++++++++++++++++ 11 files changed, 869 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/unifiprotect/models.py create mode 100644 homeassistant/components/unifiprotect/switch.py create mode 100644 homeassistant/components/unifiprotect/utils.py create mode 100644 tests/components/unifiprotect/test_switch.py diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index cc3d842840c..3d51a524c42 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -41,4 +41,10 @@ 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" -PLATFORMS = [Platform.BUTTON, Platform.CAMERA, Platform.LIGHT, Platform.MEDIA_PLAYER] +PLATFORMS = [ + Platform.BUTTON, + Platform.CAMERA, + Platform.LIGHT, + Platform.MEDIA_PLAYER, + Platform.SWITCH, +] diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 12e5ccf9456..43f3f2b8541 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -1,7 +1,18 @@ """Shared Entity definition for UniFi Protect Integration.""" from __future__ import annotations -from pyunifiprotect.data import ProtectAdoptableDeviceModel, StateType +from collections.abc import Sequence +import logging + +from pyunifiprotect.data import ( + Camera, + Light, + ModelType, + ProtectAdoptableDeviceModel, + Sensor, + StateType, + Viewer, +) from homeassistant.core import callback import homeassistant.helpers.device_registry as dr @@ -9,6 +20,72 @@ from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN from .data import ProtectData +from .models import ProtectRequiredKeysMixin +from .utils import get_nested_attr + +_LOGGER = logging.getLogger(__name__) + + +@callback +def _async_device_entities( + data: ProtectData, + klass: type[ProtectDeviceEntity], + model_type: ModelType, + descs: Sequence[ProtectRequiredKeysMixin], +) -> list[ProtectDeviceEntity]: + if len(descs) == 0: + return [] + + entities: list[ProtectDeviceEntity] = [] + for device in data.get_by_types({model_type}): + assert isinstance(device, (Camera, Light, Sensor, Viewer)) + for description in descs: + assert isinstance(description, EntityDescription) + if description.ufp_required_field: + required_field = get_nested_attr(device, description.ufp_required_field) + if not required_field: + continue + + entities.append( + klass( + data, + device=device, + description=description, + ) + ) + _LOGGER.debug( + "Adding %s entity %s for %s", + klass.__name__, + description.name, + device.name, + ) + + return entities + + +@callback +def async_all_device_entities( + data: ProtectData, + klass: type[ProtectDeviceEntity], + camera_descs: Sequence[ProtectRequiredKeysMixin] | None = None, + light_descs: Sequence[ProtectRequiredKeysMixin] | None = None, + sense_descs: Sequence[ProtectRequiredKeysMixin] | None = None, + viewport_descs: Sequence[ProtectRequiredKeysMixin] | None = None, + all_descs: Sequence[ProtectRequiredKeysMixin] | None = None, +) -> list[ProtectDeviceEntity]: + """Generate a list of all the device entities.""" + all_descs = list(all_descs or []) + 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 + + 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) + ) class ProtectDeviceEntity(Entity): diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py new file mode 100644 index 00000000000..c3862fda4eb --- /dev/null +++ b/homeassistant/components/unifiprotect/models.py @@ -0,0 +1,12 @@ +"""The unifiprotect integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ProtectRequiredKeysMixin: + """Mixin for required keys.""" + + ufp_required_field: str | None = None + ufp_value: str | None = None diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py new file mode 100644 index 00000000000..0ebfb81f306 --- /dev/null +++ b/homeassistant/components/unifiprotect/switch.py @@ -0,0 +1,253 @@ +"""This component provides Switches for UniFi Protect.""" +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Any + +from pyunifiprotect.data import Camera, RecordingMode, VideoMode +from pyunifiprotect.data.base import ProtectAdoptableDeviceModel + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +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__) + + +@dataclass +class ProtectSwitchEntityDescription(ProtectRequiredKeysMixin, SwitchEntityDescription): + """Describes UniFi Protect Switch entity.""" + + ufp_set_function: str | None = None + + +_KEY_STATUS_LIGHT = "status_light" +_KEY_HDR_MODE = "hdr_mode" +_KEY_HIGH_FPS = "high_fps" +_KEY_PRIVACY_MODE = "privacy_mode" +_KEY_SYSTEM_SOUNDS = "system_sounds" +_KEY_OSD_NAME = "osd_name" +_KEY_OSD_DATE = "osd_date" +_KEY_OSD_LOGO = "osd_logo" +_KEY_OSD_BITRATE = "osd_bitrate" +_KEY_SMART_PERSON = "smart_person" +_KEY_SMART_VEHICLE = "smart_vehicle" +_KEY_SSH = "ssh" + +ALL_DEVICES_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( + ProtectSwitchEntityDescription( + key=_KEY_SSH, + name="SSH Enabled", + icon="mdi:lock", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ufp_value="is_ssh_enabled", + ufp_set_function="set_ssh", + ), +) + +CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( + ProtectSwitchEntityDescription( + key=_KEY_STATUS_LIGHT, + name="Status Light On", + icon="mdi:led-on", + entity_category=EntityCategory.CONFIG, + ufp_required_field="feature_flags.has_led_status", + ufp_value="led_settings.is_enabled", + ufp_set_function="set_status_light", + ), + ProtectSwitchEntityDescription( + key=_KEY_HDR_MODE, + name="HDR Mode", + icon="mdi:brightness-7", + entity_category=EntityCategory.CONFIG, + ufp_required_field="feature_flags.has_hdr", + ufp_value="hdr_mode", + ufp_set_function="set_hdr", + ), + ProtectSwitchEntityDescription( + key=_KEY_HIGH_FPS, + name="High FPS", + icon="mdi:video-high-definition", + entity_category=EntityCategory.CONFIG, + ufp_required_field="feature_flags.has_highfps", + ufp_value="video_mode", + ), + ProtectSwitchEntityDescription( + key=_KEY_PRIVACY_MODE, + name="Privacy Mode", + icon="mdi:eye-settings", + entity_category=None, + ufp_required_field="feature_flags.has_privacy_mask", + ufp_value="is_privacy_on", + ), + ProtectSwitchEntityDescription( + key=_KEY_SYSTEM_SOUNDS, + name="System Sounds", + icon="mdi:speaker", + entity_category=EntityCategory.CONFIG, + ufp_required_field="feature_flags.has_speaker", + ufp_value="speaker_settings.are_system_sounds_enabled", + ufp_set_function="set_system_sounds", + ), + ProtectSwitchEntityDescription( + key=_KEY_OSD_NAME, + name="Overlay: Show Name", + icon="mdi:fullscreen", + entity_category=EntityCategory.CONFIG, + ufp_value="osd_settings.is_name_enabled", + ufp_set_function="set_osd_name", + ), + ProtectSwitchEntityDescription( + key=_KEY_OSD_DATE, + name="Overlay: Show Date", + icon="mdi:fullscreen", + entity_category=EntityCategory.CONFIG, + ufp_value="osd_settings.is_date_enabled", + ufp_set_function="set_osd_date", + ), + ProtectSwitchEntityDescription( + key=_KEY_OSD_LOGO, + name="Overlay: Show Logo", + icon="mdi:fullscreen", + entity_category=EntityCategory.CONFIG, + ufp_value="osd_settings.is_logo_enabled", + ufp_set_function="set_osd_logo", + ), + ProtectSwitchEntityDescription( + key=_KEY_OSD_BITRATE, + name="Overlay: Show Bitrate", + icon="mdi:fullscreen", + entity_category=EntityCategory.CONFIG, + ufp_value="osd_settings.is_debug_enabled", + ufp_set_function="set_osd_bitrate", + ), + ProtectSwitchEntityDescription( + key=_KEY_SMART_PERSON, + name="Detections: Person", + icon="mdi:walk", + entity_category=EntityCategory.CONFIG, + ufp_required_field="feature_flags.has_smart_detect", + ufp_value="is_person_detection_on", + ufp_set_function="set_person_detection", + ), + ProtectSwitchEntityDescription( + key=_KEY_SMART_VEHICLE, + name="Detections: Vehicle", + icon="mdi:car", + entity_category=EntityCategory.CONFIG, + ufp_required_field="feature_flags.has_smart_detect", + ufp_value="is_vehicle_detection_on", + ufp_set_function="set_vehicle_detection", + ), +) + + +LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( + ProtectSwitchEntityDescription( + key=_KEY_STATUS_LIGHT, + name="Status Light On", + icon="mdi:led-on", + entity_category=EntityCategory.CONFIG, + ufp_value="light_device_settings.is_indicator_enabled", + ufp_set_function="set_status_light", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors for UniFi Protect integration.""" + data: ProtectData = hass.data[DOMAIN][entry.entry_id] + entities: list[ProtectDeviceEntity] = async_all_device_entities( + data, + ProtectSwitch, + all_descs=ALL_DEVICES_SWITCHES, + camera_descs=CAMERA_SWITCHES, + light_descs=LIGHT_SWITCHES, + ) + async_add_entities(entities) + + +class ProtectSwitch(ProtectDeviceEntity, SwitchEntity): + """A UniFi Protect Switch.""" + + def __init__( + self, + data: ProtectData, + device: ProtectAdoptableDeviceModel, + description: ProtectSwitchEntityDescription, + ) -> None: + """Initialize an UniFi Protect Switch.""" + self.entity_description: ProtectSwitchEntityDescription = description + super().__init__(data, device) + self._attr_name = f"{self.device.name} {self.entity_description.name}" + self._switch_type = self.entity_description.key + + if not isinstance(self.device, Camera): + return + + if self.entity_description.key == _KEY_PRIVACY_MODE: + if self.device.is_privacy_on: + self._previous_mic_level = 100 + self._previous_record_mode = RecordingMode.ALWAYS + else: + self._previous_mic_level = self.device.mic_volume + self._previous_record_mode = self.device.recording_settings.mode + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + assert self.entity_description.ufp_value is not None + + ufp_value = get_nested_attr(self.device, self.entity_description.ufp_value) + if self._switch_type == _KEY_HIGH_FPS: + return bool(ufp_value == VideoMode.HIGH_FPS) + return ufp_value is True + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + + if self.entity_description.ufp_set_function is not None: + await getattr(self.device, self.entity_description.ufp_set_function)(True) + return + + assert isinstance(self.device, Camera) + if self._switch_type == _KEY_HIGH_FPS: + _LOGGER.debug("Turning on High FPS mode") + await self.device.set_video_mode(VideoMode.HIGH_FPS) + return + if self._switch_type == _KEY_PRIVACY_MODE: + _LOGGER.debug("Turning Privacy Mode on for %s", self.device.name) + self._previous_mic_level = self.device.mic_volume + self._previous_record_mode = self.device.recording_settings.mode + await self.device.set_privacy(True, 0, RecordingMode.NEVER) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + + if self.entity_description.ufp_set_function is not None: + await getattr(self.device, self.entity_description.ufp_set_function)(False) + return + + assert isinstance(self.device, Camera) + if self._switch_type == _KEY_HIGH_FPS: + _LOGGER.debug("Turning off High FPS mode") + await self.device.set_video_mode(VideoMode.DEFAULT) + elif self._switch_type == _KEY_PRIVACY_MODE: + _LOGGER.debug("Turning Privacy Mode off for %s", self.device.name) + await self.device.set_privacy( + False, self._previous_mic_level, self._previous_record_mode + ) diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py new file mode 100644 index 00000000000..14a64798ca2 --- /dev/null +++ b/homeassistant/components/unifiprotect/utils.py @@ -0,0 +1,21 @@ +"""UniFi Protect Integration utils.""" +from __future__ import annotations + +from enum import Enum +from typing import Any + + +def get_nested_attr(obj: Any, attr: str) -> Any: + """Fetch a nested attribute.""" + attrs = attr.split(".") + + value = obj + for key in attrs: + if not hasattr(value, key): + return None + value = getattr(value, key) + + if isinstance(value, Enum): + value = value.value + + return value diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 1b547b179e2..188df3b9c0a 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -11,11 +11,13 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from pyunifiprotect.data import Camera, Light, Version, WSSubscriptionMessage +from pyunifiprotect.data.base import ProtectAdoptableDeviceModel from homeassistant.components.unifiprotect.const import DOMAIN, MIN_REQUIRED_PROTECT_V from homeassistant.const import Platform from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityDescription import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -176,3 +178,21 @@ def assert_entity_counts( assert len(entities) == total assert len(hass.states.async_all(platform.value)) == enabled + + +def ids_from_device_description( + platform: Platform, + device: ProtectAdoptableDeviceModel, + description: EntityDescription, +) -> tuple[str, str]: + """Return expected unique_id and entity_id for a give platform/device/description combination.""" + + entity_name = device.name.lower().replace(":", "").replace(" ", "_") + description_entity_name = ( + description.name.lower().replace(":", "").replace(" ", "_") + ) + + unique_id = f"{device.id}_{description.key}" + entity_id = f"{platform.value}.{entity_name}_{description_entity_name}" + + return unique_id, entity_id diff --git a/tests/components/unifiprotect/test_button.py b/tests/components/unifiprotect/test_button.py index ec267a8c68f..434348fb214 100644 --- a/tests/components/unifiprotect/test_button.py +++ b/tests/components/unifiprotect/test_button.py @@ -19,7 +19,7 @@ from .conftest import MockEntityFixture, assert_entity_counts, enable_entity async def camera_fixture( hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera ): - """Fixture for a single camera with only the button platform active, no extra setup.""" + """Fixture for a single camera for testing the button platform.""" camera_obj = mock_camera.copy(deep=True) camera_obj._api = mock_entry.api diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index ec0b4c96948..c202a345f32 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -48,7 +48,7 @@ from .conftest import ( async def camera_fixture( hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera ): - """Fixture for a single camera, no extra setup.""" + """Fixture for a single camera for testing the camera platform.""" camera_obj = mock_camera.copy(deep=True) camera_obj._api = mock_entry.api diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index 8154b5435b6..5e03e20d494 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -27,7 +27,7 @@ from .conftest import MockEntityFixture, assert_entity_counts async def light_fixture( hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light ): - """Fixture for a single light with only the button platform active, no extra setup.""" + """Fixture for a single light for testing the light platform.""" # disable pydantic validation so mocking can happen Light.__config__.validate_assignment = False diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py index 844f01b34c1..5c65d285f76 100644 --- a/tests/components/unifiprotect/test_media_player.py +++ b/tests/components/unifiprotect/test_media_player.py @@ -33,7 +33,7 @@ from .conftest import MockEntityFixture, assert_entity_counts async def camera_fixture( hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera ): - """Fixture for a single camera with only the media_player platform active, camera has speaker.""" + """Fixture for a single camera for testing the media_player platform.""" # disable pydantic validation so mocking can happen Camera.__config__.validate_assignment = False diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py new file mode 100644 index 00000000000..d2db91a9d2a --- /dev/null +++ b/tests/components/unifiprotect/test_switch.py @@ -0,0 +1,474 @@ +"""Test the UniFi Protect light platform.""" +# pylint: disable=protected-access +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +import pytest +from pyunifiprotect.data import Camera, Light +from pyunifiprotect.data.types import RecordingMode, VideoMode + +from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION +from homeassistant.components.unifiprotect.switch import ( + ALL_DEVICES_SWITCHES, + CAMERA_SWITCHES, + LIGHT_SWITCHES, + ProtectSwitchEntityDescription, +) +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, STATE_OFF, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + MockEntityFixture, + assert_entity_counts, + enable_entity, + ids_from_device_description, +) + + +@pytest.fixture(name="light") +async def light_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +): + """Fixture for a single light for testing the switch 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.is_ssh_enabled = False + light_obj.light_device_settings.is_indicator_enabled = False + + mock_entry.api.bootstrap.cameras = {} + mock_entry.api.bootstrap.lights = { + light_obj.id: light_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SWITCH, 2, 1) + + yield light_obj + + Light.__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.recording_settings.mode = RecordingMode.DETECTIONS + camera_obj.feature_flags.has_led_status = True + camera_obj.feature_flags.has_hdr = True + camera_obj.feature_flags.video_modes = [VideoMode.DEFAULT, VideoMode.HIGH_FPS] + camera_obj.feature_flags.has_privacy_mask = True + camera_obj.feature_flags.has_speaker = True + camera_obj.feature_flags.has_smart_detect = True + camera_obj.is_ssh_enabled = False + camera_obj.led_settings.is_enabled = False + camera_obj.hdr_mode = False + camera_obj.video_mode = VideoMode.DEFAULT + camera_obj.remove_privacy_zone() + camera_obj.speaker_settings.are_system_sounds_enabled = False + camera_obj.osd_settings.is_name_enabled = False + camera_obj.osd_settings.is_date_enabled = False + camera_obj.osd_settings.is_logo_enabled = False + camera_obj.osd_settings.is_debug_enabled = False + camera_obj.smart_detect_settings.object_types = [] + + mock_entry.api.bootstrap.lights = {} + 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.SWITCH, 12, 11) + + yield camera_obj + + Camera.__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.recording_settings.mode = RecordingMode.DETECTIONS + camera_obj.feature_flags.has_led_status = False + camera_obj.feature_flags.has_hdr = False + camera_obj.feature_flags.video_modes = [VideoMode.DEFAULT] + camera_obj.feature_flags.has_privacy_mask = False + camera_obj.feature_flags.has_speaker = False + camera_obj.feature_flags.has_smart_detect = False + camera_obj.is_ssh_enabled = False + camera_obj.osd_settings.is_name_enabled = False + camera_obj.osd_settings.is_date_enabled = False + 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.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.SWITCH, 5, 4) + + yield camera_obj + + Camera.__config__.validate_assignment = True + + +@pytest.fixture(name="camera_privacy") +async def camera_privacy_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.recording_settings.mode = RecordingMode.NEVER + camera_obj.feature_flags.has_led_status = False + camera_obj.feature_flags.has_hdr = False + camera_obj.feature_flags.video_modes = [VideoMode.DEFAULT] + camera_obj.feature_flags.has_privacy_mask = True + camera_obj.feature_flags.has_speaker = False + camera_obj.feature_flags.has_smart_detect = False + camera_obj.add_privacy_zone() + camera_obj.is_ssh_enabled = False + camera_obj.osd_settings.is_name_enabled = False + camera_obj.osd_settings.is_date_enabled = False + 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.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.SWITCH, 6, 5) + + yield camera_obj + + Camera.__config__.validate_assignment = True + + +async def test_switch_setup_light( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + light: Light, +): + """Test switch entity setup for light devices.""" + + entity_registry = er.async_get(hass) + + description = LIGHT_SWITCHES[0] + + unique_id, entity_id = ids_from_device_description( + Platform.SWITCH, 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 == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + description = ALL_DEVICES_SWITCHES[0] + + unique_id = f"{light.id}_{description.key}" + entity_id = f"switch.test_light_{description.name.lower().replace(' ', '_')}" + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_switch_setup_camera_all( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: Camera, +): + """Test switch entity setup for camera devices (all enabled feature flags).""" + + entity_registry = er.async_get(hass) + + for description in CAMERA_SWITCHES: + unique_id, entity_id = ids_from_device_description( + Platform.SWITCH, 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 == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + description = ALL_DEVICES_SWITCHES[0] + + description_entity_name = ( + description.name.lower().replace(":", "").replace(" ", "_") + ) + unique_id = f"{camera.id}_{description.key}" + entity_id = f"switch.test_camera_{description_entity_name}" + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_switch_setup_camera_none( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera_none: Camera, +): + """Test switch entity setup for camera devices (no enabled feature flags).""" + + entity_registry = er.async_get(hass) + + for description in CAMERA_SWITCHES: + if description.ufp_required_field is not None: + continue + + unique_id, entity_id = ids_from_device_description( + Platform.SWITCH, 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 == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + description = ALL_DEVICES_SWITCHES[0] + + description_entity_name = ( + description.name.lower().replace(":", "").replace(" ", "_") + ) + unique_id = f"{camera_none.id}_{description.key}" + entity_id = f"switch.test_camera_{description_entity_name}" + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_switch_light_status(hass: HomeAssistant, light: Light): + """Tests status light switch for lights.""" + + description = LIGHT_SWITCHES[0] + + light.__fields__["set_status_light"] = Mock() + light.set_status_light = AsyncMock() + + _, entity_id = ids_from_device_description(Platform.SWITCH, light, description) + + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + light.set_status_light.assert_called_once_with(True) + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + light.set_status_light.assert_called_with(False) + + +async def test_switch_camera_ssh( + hass: HomeAssistant, camera: Camera, mock_entry: MockEntityFixture +): + """Tests SSH switch for cameras.""" + + description = ALL_DEVICES_SWITCHES[0] + + camera.__fields__["set_ssh"] = Mock() + camera.set_ssh = AsyncMock() + + _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + camera.set_ssh.assert_called_once_with(True) + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + camera.set_ssh.assert_called_with(False) + + +@pytest.mark.parametrize("description", CAMERA_SWITCHES) +async def test_switch_camera_simple( + hass: HomeAssistant, camera: Camera, description: ProtectSwitchEntityDescription +): + """Tests all simple switches for cameras.""" + + if description.name in ("High FPS", "Privacy Mode"): + return + + assert description.ufp_set_function is not None + + camera.__fields__[description.ufp_set_function] = Mock() + setattr(camera, description.ufp_set_function, AsyncMock()) + set_method = getattr(camera, description.ufp_set_function) + + _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) + + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + set_method.assert_called_once_with(True) + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + set_method.assert_called_with(False) + + +async def test_switch_camera_highfps(hass: HomeAssistant, camera: Camera): + """Tests High FPS switch for cameras.""" + + description = CAMERA_SWITCHES[2] + + camera.__fields__["set_video_mode"] = Mock() + camera.set_video_mode = AsyncMock() + + _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) + + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + camera.set_video_mode.assert_called_once_with(VideoMode.HIGH_FPS) + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + camera.set_video_mode.assert_called_with(VideoMode.DEFAULT) + + +async def test_switch_camera_privacy(hass: HomeAssistant, camera: Camera): + """Tests Privacy Mode switch for cameras.""" + + description = CAMERA_SWITCHES[3] + + camera.__fields__["set_privacy"] = Mock() + camera.set_privacy = AsyncMock() + + _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) + + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + camera.set_privacy.assert_called_once_with(True, 0, RecordingMode.NEVER) + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + camera.set_privacy.assert_called_with( + False, camera.mic_volume, camera.recording_settings.mode + ) + + +async def test_switch_camera_privacy_already_on( + hass: HomeAssistant, camera_privacy: Camera +): + """Tests Privacy Mode switch for cameras with privacy mode defaulted on.""" + + description = CAMERA_SWITCHES[3] + + camera_privacy.__fields__["set_privacy"] = Mock() + camera_privacy.set_privacy = AsyncMock() + + _, entity_id = ids_from_device_description( + Platform.SWITCH, camera_privacy, description + ) + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + camera_privacy.set_privacy.assert_called_once_with(False, 100, RecordingMode.ALWAYS)