Migrate UniFi Protect camera config switches to the public API (#174963)

This commit is contained in:
Raphael Hehl
2026-08-23 22:32:40 +02:00
committed by GitHub
parent 74a4168e5d
commit 9d12e00daf
3 changed files with 258 additions and 39 deletions
+20 -34
View File
@@ -77,7 +77,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="status_light",
entity_category=EntityCategory.CONFIG,
ufp_required_field="feature_flags.has_led_status",
ufp_value="led_settings.is_enabled",
ufp_public_value="led_settings.is_enabled",
ufp_set_method="set_status_light_public",
ufp_perm=PermRequired.WRITE,
),
@@ -96,7 +96,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="high_fps",
entity_category=EntityCategory.CONFIG,
ufp_required_field="feature_flags.has_highfps",
ufp_value="is_high_fps_enabled",
ufp_public_value="is_high_fps_enabled",
ufp_set_method_fn=_set_highfps,
ufp_perm=PermRequired.WRITE,
),
@@ -114,7 +114,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
key="osd_name",
translation_key="overlay_show_name",
entity_category=EntityCategory.CONFIG,
ufp_value="osd_settings.is_name_enabled",
ufp_public_value="osd_settings.is_name_enabled",
ufp_set_method="set_osd_name_public",
ufp_perm=PermRequired.WRITE,
),
@@ -122,7 +122,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
key="osd_date",
translation_key="overlay_show_date",
entity_category=EntityCategory.CONFIG,
ufp_value="osd_settings.is_date_enabled",
ufp_public_value="osd_settings.is_date_enabled",
ufp_set_method="set_osd_date_public",
ufp_perm=PermRequired.WRITE,
),
@@ -130,7 +130,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
key="osd_logo",
translation_key="overlay_show_logo",
entity_category=EntityCategory.CONFIG,
ufp_value="osd_settings.is_logo_enabled",
ufp_public_value="osd_settings.is_logo_enabled",
ufp_set_method="set_osd_logo_public",
ufp_perm=PermRequired.WRITE,
),
@@ -138,7 +138,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
key="osd_bitrate",
translation_key="overlay_show_nerd_mode",
entity_category=EntityCategory.CONFIG,
ufp_value="osd_settings.is_debug_enabled",
ufp_public_value="osd_settings.is_debug_enabled",
ufp_set_method="set_osd_nerd_mode_public",
ufp_perm=PermRequired.WRITE,
),
@@ -165,8 +165,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="detections_person",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_person",
ufp_value="is_person_detection_on",
ufp_enabled="is_recording_enabled",
ufp_public_value="is_person_detection_on",
ufp_set_method="set_person_detection_public",
ufp_perm=PermRequired.WRITE,
),
@@ -175,8 +174,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="detections_vehicle",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_vehicle",
ufp_value="is_vehicle_detection_on",
ufp_enabled="is_recording_enabled",
ufp_public_value="is_vehicle_detection_on",
ufp_set_method="set_vehicle_detection_public",
ufp_perm=PermRequired.WRITE,
),
@@ -185,8 +183,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="detections_animal",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_animal",
ufp_value="is_animal_detection_on",
ufp_enabled="is_recording_enabled",
ufp_public_value="is_animal_detection_on",
ufp_set_method="set_animal_detection_public",
ufp_perm=PermRequired.WRITE,
),
@@ -195,8 +192,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="detections_package",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_package",
ufp_value="is_package_detection_on",
ufp_enabled="is_recording_enabled",
ufp_public_value="is_package_detection_on",
ufp_set_method="set_package_detection_public",
ufp_perm=PermRequired.WRITE,
),
@@ -205,8 +201,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="detections_license_plate",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_license_plate",
ufp_value="is_license_plate_detection_on",
ufp_enabled="is_recording_enabled",
ufp_public_value="is_license_plate_detection_on",
ufp_set_method="set_license_plate_detection_public",
ufp_perm=PermRequired.WRITE,
),
@@ -215,8 +210,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="detections_smoke",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_smoke",
ufp_value="is_smoke_detection_on",
ufp_enabled="is_recording_enabled",
ufp_public_value="is_smoke_detection_on",
ufp_set_method="set_smoke_detection_public",
ufp_perm=PermRequired.WRITE,
),
@@ -225,8 +219,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="detections_co_alarm",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_co",
ufp_value="is_co_detection_on",
ufp_enabled="is_recording_enabled",
ufp_public_value="is_co_detection_on",
ufp_set_method="set_co_detection_public",
ufp_perm=PermRequired.WRITE,
),
@@ -235,8 +228,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="detections_siren",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_siren",
ufp_value="is_siren_detection_on",
ufp_enabled="is_recording_enabled",
ufp_public_value="is_siren_detection_on",
ufp_set_method="set_siren_detection_public",
ufp_perm=PermRequired.WRITE,
),
@@ -245,8 +237,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="detections_baby_cry",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_baby_cry",
ufp_value="is_baby_cry_detection_on",
ufp_enabled="is_recording_enabled",
ufp_public_value="is_baby_cry_detection_on",
ufp_set_method="set_baby_cry_detection_public",
ufp_perm=PermRequired.WRITE,
),
@@ -255,8 +246,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="detections_speak",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_speaking",
ufp_value="is_speaking_detection_on",
ufp_enabled="is_recording_enabled",
ufp_public_value="is_speaking_detection_on",
ufp_set_method="set_speaking_detection_public",
ufp_perm=PermRequired.WRITE,
),
@@ -265,8 +255,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="detections_bark",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_bark",
ufp_value="is_bark_detection_on",
ufp_enabled="is_recording_enabled",
ufp_public_value="is_bark_detection_on",
ufp_set_method="set_bark_detection_public",
ufp_perm=PermRequired.WRITE,
),
@@ -275,9 +264,8 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="detections_car_alarm",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_car_alarm",
ufp_value="is_car_alarm_detection_on",
ufp_enabled="is_recording_enabled",
# Public API renamed "car alarm" to "burglar"; internal model keeps the legacy name.
ufp_public_value="is_car_alarm_detection_on",
ufp_set_method="set_burglar_detection_public",
ufp_perm=PermRequired.WRITE,
),
@@ -286,8 +274,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="detections_car_horn",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_car_horn",
ufp_value="is_car_horn_detection_on",
ufp_enabled="is_recording_enabled",
ufp_public_value="is_car_horn_detection_on",
ufp_set_method="set_car_horn_detection_public",
ufp_perm=PermRequired.WRITE,
),
@@ -296,8 +283,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
translation_key="detections_glass_break",
entity_category=EntityCategory.CONFIG,
ufp_required_field="can_detect_glass_break",
ufp_value="is_glass_break_detection_on",
ufp_enabled="is_recording_enabled",
ufp_public_value="is_glass_break_detection_on",
ufp_set_method="set_glass_break_detection_public",
ufp_perm=PermRequired.WRITE,
),
@@ -1,5 +1,6 @@
"""Test the UniFi Protect switch platform."""
from typing import Any
from unittest.mock import AsyncMock, Mock, call
import pytest
@@ -44,9 +45,11 @@ from .utils import (
enable_entity,
ids_from_device_description,
init_entry,
make_public_camera,
make_public_light,
public_device_ws_message,
remove_entities,
setup_public_camera,
setup_public_light,
)
@@ -194,6 +197,7 @@ async def test_switch_setup_camera_all(
) -> 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)
@@ -237,6 +241,7 @@ async def test_switch_setup_camera_none(
) -> 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)
@@ -378,6 +383,7 @@ async def test_switch_camera_simple(
) -> 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)
@@ -408,6 +414,7 @@ async def test_switch_camera_highfps(
) -> 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)
@@ -506,6 +513,7 @@ async def test_switch_camera_detections_public_api(
SmartDetectAudioType.GLASS_BREAK,
]
setup_public_camera(ufp)
await init_entry(hass, ufp, [doorbell])
assert description.ufp_set_method is not None
@@ -529,6 +537,205 @@ async def test_switch_camera_detections_public_api(
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:
+31 -5
View File
@@ -25,15 +25,18 @@ from uiprotect.data import (
Sensor,
SmartDetectAudioType,
SmartDetectObjectType,
VideoMode,
WSSubscriptionMessage,
)
from uiprotect.data.bootstrap import ProtectDeviceRef
from uiprotect.data.public_devices import (
PublicCamera,
PublicCameraLedSettings,
PublicHdrMode,
PublicLight,
PublicLightDeviceSettings,
PublicLightModeSettings,
PublicOsdSettings,
PublicSensor,
PublicSensorLeakSettings,
PublicSensorMotionSettingsRead,
@@ -425,6 +428,12 @@ def make_public_camera(
camera: Camera,
*,
state: DeviceState | None = None,
status_light: bool = False,
osd_name: bool = False,
osd_date: bool = False,
osd_logo: bool = False,
osd_debug: bool = False,
video_mode: VideoMode | None = None,
is_motion_detected: bool = False,
is_smart_currently_detected: bool = False,
is_person_currently_detected: bool = False,
@@ -445,13 +454,19 @@ def make_public_camera(
mic_volume: int | None = None,
hdr_type: PublicHdrMode | None = None,
) -> Mock:
"""Build a public-API camera mirroring a private camera's migrated fields.
"""Build a public-API camera for a private camera's migrated fields.
The stream tiers/mic/HDR back the migrated stream and select entities; the
``is_*`` flags back the migrated ``ufp_public_value`` detection paths and the
``smart_detect_settings`` types back the per-type ``ufp_public_enabled_fn``
gates (default: all types enabled). ``mic_volume`` and ``hdr_type`` default to
values derived from the private fixture so the public mirror matches it.
gates (default: all types enabled). ``state``, ``video_mode``, ``mic_volume``
and ``hdr_type`` (derived from the private ``hdr_mode_display``) mirror the
private camera when not overridden.
``status_light`` and the ``osd_*`` flags deliberately default to off instead
of mirroring, so a test overriding one sets a value the private object would
not produce and a wrong ``ufp_public_value``/``ufp_public_value_fn`` fails
the test.
"""
public = Mock(spec=PublicCamera)
public.id = camera.id
@@ -461,6 +476,14 @@ def make_public_camera(
public.type = camera.type
public.model = ModelType.CAMERA
public.state = DeviceState[camera.state.name] if state is None else state
public.led_settings = PublicCameraLedSettings(is_enabled=status_light)
public.osd_settings = PublicOsdSettings(
is_name_enabled=osd_name,
is_date_enabled=osd_date,
is_logo_enabled=osd_logo,
is_debug_enabled=osd_debug,
)
public.video_mode = camera.video_mode if video_mode is None else video_mode
public.mic_volume = camera.mic_volume if mic_volume is None else mic_volume
public.is_motion_detected = is_motion_detected
public.is_smart_currently_detected = is_smart_currently_detected
@@ -482,12 +505,15 @@ def make_public_camera(
audio_types=_ALL_AUDIO_TYPES if audio_types is None else audio_types,
)
# A Mock(spec) does not evaluate properties, so mirror the PublicCamera
# parity properties the migrated detection sensors gate on using the
# library's own logic.
# parity properties the migrated switches read and the detection sensors
# gate on, using the library's own logic.
for name in (
"is_high_fps_enabled",
"is_person_detection_on",
"is_vehicle_detection_on",
"is_animal_detection_on",
"is_package_detection_on",
"is_license_plate_detection_on",
"is_smoke_detection_on",
"is_co_detection_on",
"is_siren_detection_on",