mirror of
https://github.com/home-assistant/core.git
synced 2026-09-06 21:41:28 +01:00
1108 lines
35 KiB
Python
1108 lines
35 KiB
Python
"""Test the UniFi Protect switch platform."""
|
|
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, Mock, call
|
|
|
|
import pytest
|
|
from uiprotect.data import (
|
|
Camera,
|
|
Light,
|
|
Permission,
|
|
PublicHdrMode,
|
|
RecordingMode,
|
|
Sensor,
|
|
SmartDetectAudioType,
|
|
SmartDetectObjectType,
|
|
VideoMode,
|
|
)
|
|
from uiprotect.data.public_devices import SensorFeatureCapability
|
|
from uiprotect.exceptions import ClientError, NotAuthorized
|
|
|
|
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION, DOMAIN
|
|
from homeassistant.components.unifiprotect.switch import (
|
|
ATTR_PREV_MIC,
|
|
ATTR_PREV_RECORD,
|
|
CAMERA_SWITCHES,
|
|
LIGHT_SWITCHES,
|
|
PRIVACY_MODE_SWITCH,
|
|
SENSE_SWITCHES,
|
|
ProtectSwitchEntityDescription,
|
|
)
|
|
from homeassistant.const import (
|
|
ATTR_ATTRIBUTION,
|
|
ATTR_ENTITY_ID,
|
|
STATE_OFF,
|
|
STATE_ON,
|
|
STATE_UNAVAILABLE,
|
|
Platform,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.helpers import entity_registry as er
|
|
|
|
from . import patch_ufp_method
|
|
from .utils import (
|
|
MockUFPFixture,
|
|
adopt_devices,
|
|
assert_entity_counts,
|
|
enable_entity,
|
|
ids_from_device_description,
|
|
init_entry,
|
|
make_public_camera,
|
|
make_public_light,
|
|
make_public_sensor,
|
|
public_device_ws_message,
|
|
remove_entities,
|
|
setup_public_camera,
|
|
setup_public_light,
|
|
setup_public_sensor,
|
|
)
|
|
|
|
CAMERA_SWITCHES_BASIC = [
|
|
d
|
|
for d in CAMERA_SWITCHES
|
|
if (
|
|
not d.translation_key.startswith("detections_")
|
|
and d.key not in {"ssh", "color_night_vision", "track_person", "hdr_mode"}
|
|
)
|
|
or d.key
|
|
in {
|
|
"detections_motion",
|
|
"detections_person",
|
|
"detections_vehicle",
|
|
"detections_animal",
|
|
}
|
|
]
|
|
CAMERA_SWITCHES_NO_EXTRA = [
|
|
d
|
|
for d in CAMERA_SWITCHES_BASIC
|
|
if d.key not in ("high_fps", "privacy_mode", "hdr_mode")
|
|
]
|
|
|
|
|
|
async def test_switch_camera_remove(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera
|
|
) -> None:
|
|
"""Test removing and re-adding a camera device."""
|
|
|
|
ufp.api.bootstrap.nvr.system_info.ustorage = None
|
|
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
|
assert_entity_counts(hass, Platform.SWITCH, 17, 15)
|
|
await remove_entities(hass, ufp, [doorbell, unadopted_camera])
|
|
assert_entity_counts(hass, Platform.SWITCH, 2, 2)
|
|
await adopt_devices(hass, ufp, [doorbell, unadopted_camera])
|
|
assert_entity_counts(hass, Platform.SWITCH, 17, 15)
|
|
|
|
|
|
async def test_switch_light_remove(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
|
|
) -> None:
|
|
"""Test removing and re-adding a light device."""
|
|
|
|
ufp.api.bootstrap.nvr.system_info.ustorage = None
|
|
await init_entry(hass, ufp, [light])
|
|
assert_entity_counts(hass, Platform.SWITCH, 4, 3)
|
|
await remove_entities(hass, ufp, [light])
|
|
assert_entity_counts(hass, Platform.SWITCH, 2, 2)
|
|
await adopt_devices(hass, ufp, [light])
|
|
assert_entity_counts(hass, Platform.SWITCH, 4, 3)
|
|
|
|
|
|
async def test_switch_nvr(hass: HomeAssistant, ufp: MockUFPFixture) -> None:
|
|
"""Test switch entity setup for light devices."""
|
|
|
|
await init_entry(hass, ufp, [])
|
|
|
|
assert_entity_counts(hass, Platform.SWITCH, 2, 2)
|
|
|
|
nvr = ufp.api.bootstrap.nvr
|
|
entity_id = "switch.unifiprotect_insights_enabled"
|
|
|
|
with patch_ufp_method(nvr, "set_insights", new_callable=AsyncMock) as mock_method:
|
|
await hass.services.async_call(
|
|
"switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_method.assert_called_once_with(True)
|
|
|
|
await hass.services.async_call(
|
|
"switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_method.assert_called_with(False)
|
|
|
|
|
|
async def test_switch_setup_no_perm(
|
|
hass: HomeAssistant,
|
|
ufp: MockUFPFixture,
|
|
light: Light,
|
|
doorbell: Camera,
|
|
) -> None:
|
|
"""Test switch entity setup for light devices."""
|
|
|
|
ufp.api.bootstrap.auth_user.all_permissions = [
|
|
Permission.unifi_dict_to_dict({"rawPermission": "light:read:*"})
|
|
]
|
|
|
|
await init_entry(hass, ufp, [light, doorbell])
|
|
|
|
assert_entity_counts(hass, Platform.SWITCH, 0, 0)
|
|
|
|
|
|
async def test_switch_setup_light(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
ufp: MockUFPFixture,
|
|
light: Light,
|
|
) -> None:
|
|
"""Test switch entity setup for light devices."""
|
|
|
|
setup_public_light(ufp)
|
|
await init_entry(hass, ufp, [light])
|
|
assert_entity_counts(hass, Platform.SWITCH, 4, 3)
|
|
|
|
description = LIGHT_SWITCHES[1]
|
|
|
|
unique_id, entity_id = await ids_from_device_description(
|
|
hass, 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 = LIGHT_SWITCHES[0]
|
|
|
|
unique_id = f"{light.mac}_{description.key}"
|
|
entity_id = f"switch.test_light_{description.translation_key}"
|
|
|
|
entity = entity_registry.async_get(entity_id)
|
|
assert entity
|
|
assert entity.disabled is True
|
|
assert entity.unique_id == unique_id
|
|
|
|
await enable_entity(hass, ufp.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,
|
|
entity_registry: er.EntityRegistry,
|
|
ufp: MockUFPFixture,
|
|
doorbell: Camera,
|
|
) -> None:
|
|
"""Test switch entity setup for camera devices (all enabled feature flags)."""
|
|
|
|
setup_public_camera(ufp)
|
|
await init_entry(hass, ufp, [doorbell])
|
|
assert_entity_counts(hass, Platform.SWITCH, 17, 15)
|
|
|
|
for description in CAMERA_SWITCHES_BASIC:
|
|
unique_id, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, doorbell, 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 = CAMERA_SWITCHES[0]
|
|
|
|
unique_id = f"{doorbell.mac}_{description.key}"
|
|
entity_id = f"switch.test_camera_{description.translation_key}"
|
|
|
|
entity = entity_registry.async_get(entity_id)
|
|
assert entity
|
|
assert entity.disabled is True
|
|
assert entity.unique_id == unique_id
|
|
|
|
await enable_entity(hass, ufp.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,
|
|
entity_registry: er.EntityRegistry,
|
|
ufp: MockUFPFixture,
|
|
camera: Camera,
|
|
) -> None:
|
|
"""Test switch entity setup for camera devices (no enabled feature flags)."""
|
|
|
|
setup_public_camera(ufp)
|
|
await init_entry(hass, ufp, [camera])
|
|
assert_entity_counts(hass, Platform.SWITCH, 8, 7)
|
|
|
|
for description in CAMERA_SWITCHES_BASIC:
|
|
if description.ufp_required_field is not None:
|
|
continue
|
|
|
|
unique_id, entity_id = await ids_from_device_description(
|
|
hass, 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 = CAMERA_SWITCHES[0]
|
|
|
|
unique_id = f"{camera.mac}_{description.key}"
|
|
entity_id = f"switch.test_camera_{description.translation_key}"
|
|
|
|
entity = entity_registry.async_get(entity_id)
|
|
assert entity
|
|
assert entity.disabled is True
|
|
assert entity.unique_id == unique_id
|
|
|
|
await enable_entity(hass, ufp.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, ufp: MockUFPFixture, light: Light
|
|
) -> None:
|
|
"""Tests status light switch for lights."""
|
|
|
|
setup_public_light(ufp)
|
|
await init_entry(hass, ufp, [light])
|
|
assert_entity_counts(hass, Platform.SWITCH, 4, 3)
|
|
|
|
description = LIGHT_SWITCHES[1]
|
|
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, light, description
|
|
)
|
|
|
|
with patch_ufp_method(
|
|
light, "set_status_light_public", new_callable=AsyncMock
|
|
) as mock_method:
|
|
await hass.services.async_call(
|
|
"switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_method.assert_called_once_with(True)
|
|
|
|
await hass.services.async_call(
|
|
"switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_method.assert_called_with(False)
|
|
|
|
|
|
async def test_switch_light_status_public_value(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
|
|
) -> None:
|
|
"""Status light switch reads from the public object and refreshes on a WS update."""
|
|
|
|
setup_public_light(ufp)
|
|
await init_entry(hass, ufp, [light])
|
|
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, light, LIGHT_SWITCHES[1]
|
|
)
|
|
assert hass.states.get(entity_id).state == STATE_OFF
|
|
|
|
# The private fixture has the indicator disabled; the public ON proves the source.
|
|
public = make_public_light(light, is_indicator_enabled=True)
|
|
ufp.devices_ws_subscription(public_device_ws_message(public))
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.states.get(entity_id).state == STATE_ON
|
|
|
|
|
|
async def test_switch_light_status_unavailable_without_public(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
|
|
) -> None:
|
|
"""The migrated status light switch is unavailable without a public object."""
|
|
|
|
await init_entry(hass, ufp, [light])
|
|
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, light, LIGHT_SWITCHES[1]
|
|
)
|
|
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
|
|
|
|
|
|
async def test_switch_camera_ssh(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera
|
|
) -> None:
|
|
"""Tests SSH switch for cameras."""
|
|
|
|
await init_entry(hass, ufp, [doorbell])
|
|
assert_entity_counts(hass, Platform.SWITCH, 17, 15)
|
|
|
|
description = CAMERA_SWITCHES[0]
|
|
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, doorbell, description
|
|
)
|
|
await enable_entity(hass, ufp.entry.entry_id, entity_id)
|
|
|
|
with patch_ufp_method(doorbell, "set_ssh", new_callable=AsyncMock) as mock_method:
|
|
await hass.services.async_call(
|
|
"switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_method.assert_called_once_with(True)
|
|
|
|
await hass.services.async_call(
|
|
"switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_method.assert_called_with(False)
|
|
|
|
|
|
@pytest.mark.parametrize("description", CAMERA_SWITCHES_NO_EXTRA)
|
|
async def test_switch_camera_simple(
|
|
hass: HomeAssistant,
|
|
ufp: MockUFPFixture,
|
|
doorbell: Camera,
|
|
description: ProtectSwitchEntityDescription,
|
|
) -> None:
|
|
"""Tests all simple switches for cameras."""
|
|
|
|
setup_public_camera(ufp)
|
|
await init_entry(hass, ufp, [doorbell])
|
|
assert_entity_counts(hass, Platform.SWITCH, 17, 15)
|
|
|
|
assert description.ufp_set_method is not None
|
|
|
|
with patch_ufp_method(
|
|
doorbell, description.ufp_set_method, new_callable=AsyncMock
|
|
) as mock_method:
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, doorbell, description
|
|
)
|
|
|
|
await hass.services.async_call(
|
|
"switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_method.assert_called_once_with(True)
|
|
|
|
await hass.services.async_call(
|
|
"switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_method.assert_called_with(False)
|
|
|
|
|
|
async def test_switch_camera_highfps(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera
|
|
) -> None:
|
|
"""Tests High FPS switch for cameras."""
|
|
|
|
setup_public_camera(ufp)
|
|
await init_entry(hass, ufp, [doorbell])
|
|
assert_entity_counts(hass, Platform.SWITCH, 17, 15)
|
|
|
|
description = CAMERA_SWITCHES[3]
|
|
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, doorbell, description
|
|
)
|
|
|
|
with patch_ufp_method(
|
|
doorbell, "set_video_mode_public", new_callable=AsyncMock
|
|
) as mock_method:
|
|
await hass.services.async_call(
|
|
"switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_method.assert_called_once_with(VideoMode.HIGH_FPS)
|
|
|
|
await hass.services.async_call(
|
|
"switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_method.assert_called_with(VideoMode.DEFAULT)
|
|
|
|
|
|
CAMERA_SWITCHES_DETECTIONS_EXTRA = [
|
|
d
|
|
for d in CAMERA_SWITCHES
|
|
if d.translation_key.startswith("detections_")
|
|
and d.key
|
|
not in {
|
|
"detections_motion",
|
|
"detections_person",
|
|
"detections_vehicle",
|
|
"detections_animal",
|
|
}
|
|
]
|
|
|
|
|
|
async def test_switch_camera_hdr(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera
|
|
) -> None:
|
|
"""Tests HDR mode switch uses the public API helper."""
|
|
|
|
await init_entry(hass, ufp, [doorbell])
|
|
assert_entity_counts(hass, Platform.SWITCH, 17, 15)
|
|
|
|
description = next(d for d in CAMERA_SWITCHES if d.key == "hdr_mode")
|
|
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, doorbell, description
|
|
)
|
|
await enable_entity(hass, ufp.entry.entry_id, entity_id)
|
|
|
|
with patch_ufp_method(
|
|
doorbell, "set_hdr_mode_public", new_callable=AsyncMock
|
|
) as mock_method:
|
|
await hass.services.async_call(
|
|
"switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
await hass.services.async_call(
|
|
"switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_method.assert_has_calls(
|
|
[call(PublicHdrMode.AUTO), call(PublicHdrMode.OFF)]
|
|
)
|
|
assert mock_method.call_count == 2
|
|
|
|
|
|
@pytest.mark.parametrize("description", CAMERA_SWITCHES_DETECTIONS_EXTRA)
|
|
async def test_switch_camera_detections_public_api(
|
|
hass: HomeAssistant,
|
|
ufp: MockUFPFixture,
|
|
doorbell: Camera,
|
|
description: ProtectSwitchEntityDescription,
|
|
) -> None:
|
|
"""Tests detection switches call the public API setters."""
|
|
|
|
doorbell.feature_flags.smart_detect_types = [
|
|
SmartDetectObjectType.PERSON,
|
|
SmartDetectObjectType.VEHICLE,
|
|
SmartDetectObjectType.ANIMAL,
|
|
SmartDetectObjectType.PACKAGE,
|
|
SmartDetectObjectType.LICENSE_PLATE,
|
|
]
|
|
doorbell.feature_flags.smart_detect_audio_types = [
|
|
SmartDetectAudioType.SMOKE,
|
|
SmartDetectAudioType.CMONX,
|
|
SmartDetectAudioType.SIREN,
|
|
SmartDetectAudioType.BABY_CRY,
|
|
SmartDetectAudioType.SPEAK,
|
|
SmartDetectAudioType.BARK,
|
|
SmartDetectAudioType.BURGLAR,
|
|
SmartDetectAudioType.CAR_HORN,
|
|
SmartDetectAudioType.GLASS_BREAK,
|
|
]
|
|
|
|
setup_public_camera(ufp)
|
|
await init_entry(hass, ufp, [doorbell])
|
|
|
|
assert description.ufp_set_method is not None
|
|
assert description.ufp_set_method.endswith("_public")
|
|
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, doorbell, description
|
|
)
|
|
|
|
with patch_ufp_method(
|
|
doorbell, description.ufp_set_method, new_callable=AsyncMock
|
|
) as mock_method:
|
|
await hass.services.async_call(
|
|
"switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
await hass.services.async_call(
|
|
"switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_method.assert_has_calls([call(True), call(False)])
|
|
assert mock_method.call_count == 2
|
|
|
|
|
|
async def test_switch_camera_status_light_public_value(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera
|
|
) -> None:
|
|
"""Status light reads from the public object and refreshes on a public WS update."""
|
|
|
|
setup_public_camera(ufp)
|
|
await init_entry(hass, ufp, [doorbell])
|
|
|
|
description = next(d for d in CAMERA_SWITCHES if d.key == "status_light")
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, doorbell, description
|
|
)
|
|
assert hass.states.get(entity_id).state == STATE_OFF
|
|
|
|
public = make_public_camera(doorbell, status_light=True)
|
|
ufp.devices_ws_subscription(public_device_ws_message(public))
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.states.get(entity_id).state == STATE_ON
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("switch_key", "camera_kwarg"),
|
|
[
|
|
("osd_name", "osd_name"),
|
|
("osd_date", "osd_date"),
|
|
("osd_logo", "osd_logo"),
|
|
("osd_bitrate", "osd_debug"),
|
|
],
|
|
)
|
|
async def test_switch_camera_osd_public_value(
|
|
hass: HomeAssistant,
|
|
ufp: MockUFPFixture,
|
|
doorbell: Camera,
|
|
switch_key: str,
|
|
camera_kwarg: str,
|
|
) -> None:
|
|
"""Each OSD switch reads its own public osd_settings flag independently."""
|
|
|
|
setup_public_camera(ufp)
|
|
await init_entry(hass, ufp, [doorbell])
|
|
|
|
description = next(d for d in CAMERA_SWITCHES if d.key == switch_key)
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, doorbell, description
|
|
)
|
|
assert hass.states.get(entity_id).state == STATE_OFF
|
|
|
|
public = make_public_camera(doorbell, **{camera_kwarg: True})
|
|
ufp.devices_ws_subscription(public_device_ws_message(public))
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.states.get(entity_id).state == STATE_ON
|
|
|
|
|
|
# Only the switch under test is enabled; ``make_public_camera`` defaults both
|
|
# lists to every type, so each case pins both to keep the others off.
|
|
CAMERA_SWITCHES_DETECTION_READ = [
|
|
("smart_person", [SmartDetectObjectType.PERSON], []),
|
|
("smart_vehicle", [SmartDetectObjectType.VEHICLE], []),
|
|
("smart_animal", [SmartDetectObjectType.ANIMAL], []),
|
|
("smart_package", [SmartDetectObjectType.PACKAGE], []),
|
|
("smart_licenseplate", [SmartDetectObjectType.LICENSE_PLATE], []),
|
|
("smart_smoke", [], [SmartDetectAudioType.SMOKE]),
|
|
("smart_cmonx", [], [SmartDetectAudioType.CMONX]),
|
|
("smart_siren", [], [SmartDetectAudioType.SIREN]),
|
|
("smart_baby_cry", [], [SmartDetectAudioType.BABY_CRY]),
|
|
("smart_speak", [], [SmartDetectAudioType.SPEAK]),
|
|
("smart_bark", [], [SmartDetectAudioType.BARK]),
|
|
("smart_car_alarm", [], [SmartDetectAudioType.BURGLAR]),
|
|
("smart_car_horn", [], [SmartDetectAudioType.CAR_HORN]),
|
|
("smart_glass_break", [], [SmartDetectAudioType.GLASS_BREAK]),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("key", "object_types", "audio_types"), CAMERA_SWITCHES_DETECTION_READ
|
|
)
|
|
async def test_switch_camera_detection_public_value(
|
|
hass: HomeAssistant,
|
|
ufp: MockUFPFixture,
|
|
doorbell: Camera,
|
|
key: str,
|
|
object_types: list[SmartDetectObjectType],
|
|
audio_types: list[SmartDetectAudioType],
|
|
) -> None:
|
|
"""Each detection toggle reads its on/off state from its own public flag."""
|
|
|
|
doorbell.feature_flags.smart_detect_types = [
|
|
SmartDetectObjectType.PERSON,
|
|
SmartDetectObjectType.VEHICLE,
|
|
SmartDetectObjectType.ANIMAL,
|
|
SmartDetectObjectType.PACKAGE,
|
|
SmartDetectObjectType.LICENSE_PLATE,
|
|
]
|
|
doorbell.feature_flags.smart_detect_audio_types = [
|
|
SmartDetectAudioType.SMOKE,
|
|
SmartDetectAudioType.CMONX,
|
|
SmartDetectAudioType.SIREN,
|
|
SmartDetectAudioType.BABY_CRY,
|
|
SmartDetectAudioType.SPEAK,
|
|
SmartDetectAudioType.BARK,
|
|
SmartDetectAudioType.BURGLAR,
|
|
SmartDetectAudioType.CAR_HORN,
|
|
SmartDetectAudioType.GLASS_BREAK,
|
|
]
|
|
|
|
setup_public_camera(ufp)
|
|
|
|
async def _prime_without_camera() -> Any:
|
|
pb = ufp.api.public_bootstrap
|
|
pb.cameras = {}
|
|
return pb
|
|
|
|
ufp.api.update_public = AsyncMock(side_effect=_prime_without_camera)
|
|
|
|
await init_entry(hass, ufp, [doorbell])
|
|
|
|
description = next(d for d in CAMERA_SWITCHES if d.key == key)
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, doorbell, description
|
|
)
|
|
|
|
all_off = make_public_camera(doorbell, object_types=[], audio_types=[])
|
|
ufp.devices_ws_subscription(public_device_ws_message(all_off))
|
|
await hass.async_block_till_done()
|
|
assert hass.states.get(entity_id).state == STATE_OFF
|
|
|
|
public = make_public_camera(
|
|
doorbell, object_types=object_types, audio_types=audio_types
|
|
)
|
|
ufp.devices_ws_subscription(public_device_ws_message(public))
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.states.get(entity_id).state == STATE_ON
|
|
|
|
|
|
async def test_switch_camera_highfps_public_value(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera
|
|
) -> None:
|
|
"""The high FPS switch reads video_mode from the public object."""
|
|
|
|
setup_public_camera(ufp)
|
|
await init_entry(hass, ufp, [doorbell])
|
|
|
|
description = next(d for d in CAMERA_SWITCHES if d.key == "high_fps")
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, doorbell, description
|
|
)
|
|
assert hass.states.get(entity_id).state == STATE_OFF
|
|
|
|
public = make_public_camera(doorbell, video_mode=VideoMode.HIGH_FPS)
|
|
ufp.devices_ws_subscription(public_device_ws_message(public))
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.states.get(entity_id).state == STATE_ON
|
|
|
|
|
|
async def test_switch_camera_detection_unavailable_without_public(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera
|
|
) -> None:
|
|
"""A migrated detection toggle is unavailable without a public object."""
|
|
|
|
async def _prime_without_camera() -> Any:
|
|
pb = ufp.api.public_bootstrap
|
|
pb.cameras = {}
|
|
return pb
|
|
|
|
ufp.api.update_public = AsyncMock(side_effect=_prime_without_camera)
|
|
|
|
await init_entry(hass, ufp, [doorbell])
|
|
|
|
description = next(d for d in CAMERA_SWITCHES if d.key == "smart_person")
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, doorbell, description
|
|
)
|
|
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
|
|
|
|
|
|
async def test_switch_camera_detection_available_with_recording_disabled(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera
|
|
) -> None:
|
|
"""A migrated detection toggle stays available with recording disabled.
|
|
|
|
Unlike the legacy private-only switch, the public detection toggles no
|
|
longer gate their availability on ``is_recording_enabled`` (breaking change).
|
|
"""
|
|
|
|
doorbell.recording_settings.mode = RecordingMode.NEVER
|
|
setup_public_camera(ufp)
|
|
await init_entry(hass, ufp, [doorbell])
|
|
|
|
description = next(d for d in CAMERA_SWITCHES if d.key == "smart_person")
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, doorbell, description
|
|
)
|
|
assert hass.states.get(entity_id).state != STATE_UNAVAILABLE
|
|
|
|
|
|
async def test_switch_camera_privacy(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera
|
|
) -> None:
|
|
"""Tests Privacy Mode switch for cameras with privacy mode defaulted on."""
|
|
|
|
previous_mic = doorbell.mic_volume = 53
|
|
previous_record = doorbell.recording_settings.mode = RecordingMode.DETECTIONS
|
|
|
|
await init_entry(hass, ufp, [doorbell])
|
|
assert_entity_counts(hass, Platform.SWITCH, 17, 15)
|
|
|
|
description = PRIVACY_MODE_SWITCH
|
|
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, doorbell, description
|
|
)
|
|
|
|
state = hass.states.get(entity_id)
|
|
assert state and state.state == "off"
|
|
assert ATTR_PREV_MIC not in state.attributes
|
|
assert ATTR_PREV_RECORD not in state.attributes
|
|
|
|
with patch_ufp_method(
|
|
doorbell, "set_privacy", new_callable=AsyncMock
|
|
) as mock_set_privacy:
|
|
await hass.services.async_call(
|
|
"switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_set_privacy.assert_called_with(True, 0, RecordingMode.NEVER)
|
|
|
|
new_doorbell = doorbell.model_copy()
|
|
new_doorbell.add_privacy_zone()
|
|
new_doorbell.mic_volume = 0
|
|
new_doorbell.recording_settings.mode = RecordingMode.NEVER
|
|
ufp.api.bootstrap.cameras = {new_doorbell.id: new_doorbell}
|
|
|
|
mock_msg = Mock()
|
|
mock_msg.changed_data = {}
|
|
mock_msg.new_obj = new_doorbell
|
|
ufp.ws_msg(mock_msg)
|
|
|
|
state = hass.states.get(entity_id)
|
|
assert state and state.state == "on"
|
|
assert state.attributes[ATTR_PREV_MIC] == previous_mic
|
|
assert state.attributes[ATTR_PREV_RECORD] == previous_record.value
|
|
|
|
mock_set_privacy.reset_mock()
|
|
|
|
await hass.services.async_call(
|
|
"switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_set_privacy.assert_called_with(False, previous_mic, previous_record)
|
|
|
|
|
|
async def test_switch_camera_privacy_already_on(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera
|
|
) -> None:
|
|
"""Tests Privacy Mode switch for cameras with privacy mode defaulted on."""
|
|
|
|
doorbell.add_privacy_zone()
|
|
await init_entry(hass, ufp, [doorbell])
|
|
assert_entity_counts(hass, Platform.SWITCH, 17, 15)
|
|
|
|
description = PRIVACY_MODE_SWITCH
|
|
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, doorbell, description
|
|
)
|
|
|
|
with patch_ufp_method(
|
|
doorbell, "set_privacy", new_callable=AsyncMock
|
|
) as mock_set_privacy:
|
|
await hass.services.async_call(
|
|
"switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
mock_set_privacy.assert_called_once_with(False, 100, RecordingMode.ALWAYS)
|
|
|
|
|
|
async def test_switch_turn_on_client_error(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
|
|
) -> None:
|
|
"""Test switch turn on with ClientError raises HomeAssistantError."""
|
|
|
|
setup_public_light(ufp)
|
|
await init_entry(hass, ufp, [light])
|
|
|
|
description = LIGHT_SWITCHES[1]
|
|
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, light, description
|
|
)
|
|
|
|
with (
|
|
patch_ufp_method(
|
|
light,
|
|
"set_status_light_public",
|
|
new_callable=AsyncMock,
|
|
side_effect=ClientError("Test error"),
|
|
),
|
|
pytest.raises(HomeAssistantError),
|
|
):
|
|
await hass.services.async_call(
|
|
"switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
|
|
async def test_switch_turn_on_not_authorized(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
|
|
) -> None:
|
|
"""Test switch turn on with NotAuthorized raises HomeAssistantError."""
|
|
|
|
setup_public_light(ufp)
|
|
await init_entry(hass, ufp, [light])
|
|
|
|
description = LIGHT_SWITCHES[1]
|
|
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, light, description
|
|
)
|
|
|
|
with (
|
|
patch_ufp_method(
|
|
light,
|
|
"set_status_light_public",
|
|
new_callable=AsyncMock,
|
|
side_effect=NotAuthorized("Not authorized"),
|
|
),
|
|
pytest.raises(HomeAssistantError),
|
|
):
|
|
await hass.services.async_call(
|
|
"switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
|
|
|
|
# A USL Environmental reports these four and neither motion nor alarm-sound
|
|
# detection, so none of its capabilities back a motion or alarm switch.
|
|
_ENV_CAPABILITIES = {
|
|
SensorFeatureCapability.TEMPERATURE,
|
|
SensorFeatureCapability.HUMIDITY,
|
|
SensorFeatureCapability.LIGHT,
|
|
SensorFeatureCapability.WATER_LEAK,
|
|
}
|
|
|
|
|
|
async def test_switch_sense_capability_creation_filter(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
ufp: MockUFPFixture,
|
|
sensor_all: Sensor,
|
|
) -> None:
|
|
"""A capability map limits the config switches to the advertised capabilities."""
|
|
setup_public_sensor(ufp, capabilities=_ENV_CAPABILITIES)
|
|
await init_entry(hass, ufp, [sensor_all])
|
|
|
|
for key, created in (
|
|
("status_light", True),
|
|
("temperature", True),
|
|
("humidity", True),
|
|
("light", True),
|
|
("motion", False),
|
|
("alarm", False),
|
|
):
|
|
description = next(d for d in SENSE_SWITCHES if d.key == key)
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, sensor_all, description
|
|
)
|
|
assert (entity_registry.async_get(entity_id) is not None) is created, key
|
|
|
|
|
|
async def test_switch_sense_capability_registry_cleanup(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
ufp: MockUFPFixture,
|
|
sensor_all: Sensor,
|
|
) -> None:
|
|
"""A console upgrade removes registry entries for unsupported capabilities."""
|
|
stale = entity_registry.async_get_or_create(
|
|
Platform.SWITCH,
|
|
DOMAIN,
|
|
f"{sensor_all.mac}_motion",
|
|
config_entry=ufp.entry,
|
|
)
|
|
setup_public_sensor(ufp, capabilities=_ENV_CAPABILITIES)
|
|
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
|
|
|
|
assert entity_registry.async_get(stale.entity_id) is None
|
|
|
|
|
|
async def test_switch_sense_no_capability_map_creates_all(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
ufp: MockUFPFixture,
|
|
sensor_all: Sensor,
|
|
) -> None:
|
|
"""Without a capability map (Protect below 7.2) every config switch is created."""
|
|
setup_public_sensor(ufp)
|
|
await init_entry(hass, ufp, [sensor_all])
|
|
|
|
for description in SENSE_SWITCHES:
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, sensor_all, description
|
|
)
|
|
assert entity_registry.async_get(entity_id) is not None, description.key
|
|
|
|
|
|
async def test_switch_sense_no_capability_map_keeps_existing(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
ufp: MockUFPFixture,
|
|
sensor_all: Sensor,
|
|
) -> None:
|
|
"""Without a capability map (Protect below 7.2) nothing is removed.
|
|
|
|
The console cannot say which capabilities it lacks, so an existing entity
|
|
must survive setup instead of being deleted on a guess.
|
|
"""
|
|
existing = entity_registry.async_get_or_create(
|
|
Platform.SWITCH,
|
|
DOMAIN,
|
|
f"{sensor_all.mac}_motion",
|
|
config_entry=ufp.entry,
|
|
)
|
|
setup_public_sensor(ufp)
|
|
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
|
|
|
|
assert entity_registry.async_get(existing.entity_id) is not None
|
|
|
|
|
|
# The five sense settings the public API exposes, with the public-mock override
|
|
# that flips them and the public setter each switch must write through.
|
|
MIGRATED_SENSE_SWITCHES = [
|
|
("motion", "motion_enabled", "set_motion_status_public"),
|
|
("temperature", "temperature_enabled", "set_temperature_status_public"),
|
|
("humidity", "humidity_enabled", "set_humidity_status_public"),
|
|
("light", "light_enabled", "set_light_status_public"),
|
|
("alarm", "alarm_enabled", "set_alarm_public"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(("key", "public_kwarg", "set_method"), MIGRATED_SENSE_SWITCHES)
|
|
async def test_switch_sense_public_value(
|
|
hass: HomeAssistant,
|
|
ufp: MockUFPFixture,
|
|
sensor_all: Sensor,
|
|
key: str,
|
|
public_kwarg: str,
|
|
set_method: str,
|
|
) -> None:
|
|
"""Each migrated sense switch reads its state from the public object."""
|
|
setup_public_sensor(ufp)
|
|
await init_entry(hass, ufp, [sensor_all])
|
|
|
|
description = next(d for d in SENSE_SWITCHES if d.key == key)
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, sensor_all, description
|
|
)
|
|
assert hass.states.get(entity_id).state == STATE_ON
|
|
|
|
# every setting is enabled on the private fixture, so a public OFF can only
|
|
# come from the public object
|
|
public = make_public_sensor(sensor_all, **{public_kwarg: False})
|
|
ufp.devices_ws_subscription(public_device_ws_message(public))
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.states.get(entity_id).state == STATE_OFF
|
|
|
|
|
|
@pytest.mark.parametrize(("key", "public_kwarg", "set_method"), MIGRATED_SENSE_SWITCHES)
|
|
async def test_switch_sense_set_public(
|
|
hass: HomeAssistant,
|
|
ufp: MockUFPFixture,
|
|
sensor_all: Sensor,
|
|
key: str,
|
|
public_kwarg: str,
|
|
set_method: str,
|
|
) -> None:
|
|
"""Each migrated sense switch writes through the public API."""
|
|
setup_public_sensor(ufp)
|
|
await init_entry(hass, ufp, [sensor_all])
|
|
|
|
description = next(d for d in SENSE_SWITCHES if d.key == key)
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, sensor_all, description
|
|
)
|
|
|
|
with patch_ufp_method(
|
|
sensor_all, set_method, new_callable=AsyncMock
|
|
) as mock_method:
|
|
await hass.services.async_call(
|
|
"switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
mock_method.assert_called_once_with(False)
|
|
|
|
|
|
async def test_switch_sense_unavailable_without_public(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor
|
|
) -> None:
|
|
"""A migrated sense switch is unavailable without a public object."""
|
|
await init_entry(hass, ufp, [sensor_all])
|
|
|
|
description = next(d for d in SENSE_SWITCHES if d.key == "motion")
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, sensor_all, description
|
|
)
|
|
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
|
|
|
|
|
|
async def test_switch_sense_status_light_stays_private(
|
|
hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor
|
|
) -> None:
|
|
"""The status light has no public counterpart, so it reads the private object.
|
|
|
|
Unlike the migrated switches it must stay usable without a public object.
|
|
"""
|
|
await init_entry(hass, ufp, [sensor_all])
|
|
|
|
description = next(d for d in SENSE_SWITCHES if d.key == "status_light")
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, sensor_all, description
|
|
)
|
|
assert hass.states.get(entity_id).state == STATE_ON
|
|
|
|
with patch_ufp_method(
|
|
sensor_all, "set_status_light", new_callable=AsyncMock
|
|
) as mock_method:
|
|
await hass.services.async_call(
|
|
"switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
|
)
|
|
mock_method.assert_called_once_with(False)
|
|
|
|
|
|
async def test_switch_sense_public_switches_ignore_local_permissions(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
ufp: MockUFPFixture,
|
|
sensor_all: Sensor,
|
|
) -> None:
|
|
"""A read-only local user keeps the migrated switches but not the private one.
|
|
|
|
The migrated switches write through the API key, so the local user's write
|
|
bit must not gate them; the status light still uses a private setter and
|
|
stays behind PermRequired.WRITE.
|
|
"""
|
|
ufp.api.bootstrap.auth_user.all_permissions = [
|
|
Permission.unifi_dict_to_dict({"rawPermission": "sensor:read:*"})
|
|
]
|
|
setup_public_sensor(ufp)
|
|
await init_entry(hass, ufp, [sensor_all])
|
|
|
|
for key, _public_kwarg, _set_method in MIGRATED_SENSE_SWITCHES:
|
|
description = next(d for d in SENSE_SWITCHES if d.key == key)
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, sensor_all, description
|
|
)
|
|
assert entity_registry.async_get(entity_id) is not None, key
|
|
|
|
description = next(d for d in SENSE_SWITCHES if d.key == "status_light")
|
|
_, entity_id = await ids_from_device_description(
|
|
hass, Platform.SWITCH, sensor_all, description
|
|
)
|
|
assert entity_registry.async_get(entity_id) is None
|