Gate the UniFi Protect sense config entities on the capability map (#177593)

This commit is contained in:
Raphael Hehl
2026-08-26 00:21:22 +02:00
committed by GitHub
parent 43290dd4fe
commit 440851fbc2
11 changed files with 687 additions and 29 deletions
@@ -38,7 +38,7 @@ from .const import (
PLATFORMS,
)
from .data import ProtectData, UFPConfigEntry
from .migrate import async_migrate_data
from .migrate import async_deprecate_sense_setting_mirrors, async_migrate_data
from .services import async_setup_services
from .utils import (
_async_unifi_mac_from_hass,
@@ -205,6 +205,9 @@ async def _async_setup_entry(
data_service.nvr_device_id = nvr_device.id
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# The replacement switch/number entities only exist once platforms are set
# up, so this migration runs here rather than with the others above.
async_deprecate_sense_setting_mirrors(hass, entry, bootstrap)
hass.http.register_view(ThumbnailProxyView(hass))
hass.http.register_view(SnapshotProxyView(hass))
hass.http.register_view(VideoProxyView(hass))
@@ -7,6 +7,7 @@ import logging
from typing import TYPE_CHECKING, override
from uiprotect.data import ModelType, ProtectAdoptableDeviceModel
from uiprotect.data.public_devices import SensorFeatureCapability
from homeassistant.components.button import (
ButtonDeviceClass,
@@ -28,6 +29,7 @@ from .entity import (
ProtectSettableKeysMixin,
T,
async_all_device_entities,
async_remove_unsupported_sense_entities,
)
from .utils import async_ufp_instance_command
@@ -72,6 +74,7 @@ SENSOR_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = (
key="clear_tamper",
translation_key="clear_tamper",
ufp_press="clear_tamper",
ufp_capability=SensorFeatureCapability.TAMPER,
ufp_perm=PermRequired.WRITE,
),
)
@@ -114,6 +117,7 @@ async def async_setup_entry(
) -> None:
"""Discover devices on a UniFi Protect NVR."""
data = entry.runtime_data
async_remove_unsupported_sense_entities(hass, Platform.BUTTON, data, SENSOR_BUTTONS)
adopt_entities = partial(
async_all_device_entities,
@@ -195,13 +195,16 @@ def _async_repair_if_used(
issue_id: str,
translation_key: str,
placeholders: dict[str, str] | None = None,
breaks_in: str | None = None,
) -> None:
"""Raise a persistent repair before removing an entity that is still in use.
"""Raise a repair for an entity that is going away and is still in use.
Removal cannot rewrite the user's automations/scripts, so a persistent repair
lists the affected ones (the caller supplies any replacement hint via
``placeholders``). Disabled entities are skipped: they are not active in any
automation.
Neither a removal nor a deprecation can rewrite the user's
automations/scripts, so the repair lists the affected ones (the caller
supplies any replacement hint via ``placeholders``). Disabled entities are
skipped: they are not active in any automation. Pass ``breaks_in`` while the
entity still exists; the repair then clears itself once the last usage is
gone, where a removal repair has to persist.
"""
if entity.disabled_by is not None:
return
@@ -210,13 +213,16 @@ def _async_repair_if_used(
| set(scripts_with_entity(hass, entity.entity_id))
)
if not items:
if breaks_in is not None:
ir.async_delete_issue(hass, DOMAIN, issue_id)
return
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=False,
is_persistent=True,
is_persistent=breaks_in is None,
breaks_in_ha_version=breaks_in,
severity=IssueSeverity.WARNING,
translation_key=translation_key,
translation_placeholders={
@@ -256,6 +262,77 @@ def async_remove_package_binary_sensor(
registry.async_remove(entity.entity_id)
# Release that removes the deprecated mirrors.
SENSE_SETTING_MIRROR_BREAKS_IN = "2026.11.0"
# Sense settings whose read-only mirror is deprecated, keyed by the mirror's own
# (platform, key) and pointing at the (platform, key) of the control that
# replaces it. Camera and light entities reuse these key strings, so the match
# is scoped to the sensor MACs below.
_SENSE_SETTING_REPLACEMENTS: dict[tuple[str, str], tuple[Platform, str]] = {
(Platform.BINARY_SENSOR, "motion_enabled"): (Platform.SWITCH, "motion"),
(Platform.BINARY_SENSOR, "temperature"): (Platform.SWITCH, "temperature"),
(Platform.BINARY_SENSOR, "humidity"): (Platform.SWITCH, "humidity"),
(Platform.BINARY_SENSOR, "light"): (Platform.SWITCH, "light"),
(Platform.BINARY_SENSOR, "alarm"): (Platform.SWITCH, "alarm"),
(Platform.SENSOR, "sensitivity"): (Platform.NUMBER, "sensitivity"),
}
@callback
def async_deprecate_sense_setting_mirrors(
hass: HomeAssistant, entry: UFPConfigEntry, bootstrap: Bootstrap
) -> None:
"""Deprecate the read-only mirrors of the sense setting controls.
Those controls write through the public API, which the local user's write
permission does not gate, so the switch or number is now available to every
user and the ``PermRequired.NO_WRITE`` mirror only duplicates its state.
The mirrors keep working until the removal, so a dashboard or automation
referencing one does not break without warning. Two releases is enough
here: the replacement holds the same state, so the migration is an entity
id swap, and the repair points at the exact entity to swap in.
Runs after platform setup, unlike the other migrations in this file: the
repair needs the replacement switch/number to already be in the registry
so it can name it, and that entity is only created once the platform is
set up.
Added in 2026.9.0
"""
if not (macs := {sensor.mac for sensor in bootstrap.sensors.values()}):
return
registry = er.async_get(hass)
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
mac, _, key = entity.unique_id.partition("_")
replacement = _SENSE_SETTING_REPLACEMENTS.get((entity.domain, key))
if replacement is None or mac not in macs:
continue
replacement_platform, replacement_key = replacement
# The device may not support the setting at all (no capability match),
# in which case there is nothing to point the repair at.
if replacement_entity_id := registry.async_get_entity_id(
replacement_platform, DOMAIN, f"{mac}_{replacement_key}"
):
_async_repair_if_used(
hass,
entity,
f"sense_setting_mirror_deprecated_{entity.unique_id}",
"sense_setting_mirror_deprecated",
{"replacement": replacement_entity_id},
breaks_in=SENSE_SETTING_MIRROR_BREAKS_IN,
)
else:
_async_repair_if_used(
hass,
entity,
f"sense_setting_mirror_deprecated_{entity.unique_id}",
"sense_setting_mirror_deprecated_no_replacement",
breaks_in=SENSE_SETTING_MIRROR_BREAKS_IN,
)
@callback
def async_deprecate_hdr(hass: HomeAssistant, entry: UFPConfigEntry) -> None:
"""Check for usages of hdr_mode switch and raise repair if it is used.
@@ -203,7 +203,6 @@ SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
ufp_public_value="motion_settings.sensitivity",
ufp_set_method="set_motion_sensitivity_public",
ufp_capability=SensorFeatureCapability.MOTION,
ufp_perm=PermRequired.WRITE,
),
)
@@ -829,6 +829,14 @@
}
},
"title": "No stream is available for camera {camera}"
},
"sense_setting_mirror_deprecated": {
"description": "The read-only entity `{entity_id}` is deprecated and will be removed in a future release; use `{replacement}` instead, which holds the same state and can also change it.\n\nUpdate the following automations and scripts:\n{items}",
"title": "Read-only sensor setting entity deprecated"
},
"sense_setting_mirror_deprecated_no_replacement": {
"description": "The read-only entity `{entity_id}` is deprecated and will be removed in a future release. The sensor does not support this setting, so there is no replacement.\n\nUpdate the following automations and scripts:\n{items}",
"title": "Read-only sensor setting entity deprecated"
}
},
"options": {
+21 -16
View File
@@ -17,9 +17,10 @@ from uiprotect.data import (
RelayOutputState,
VideoMode,
)
from uiprotect.data.public_devices import SensorFeatureCapability
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
@@ -39,6 +40,7 @@ from .entity import (
ProtectSettableKeysMixin,
T,
async_all_device_entities,
async_remove_unsupported_sense_entities,
)
from .utils import async_ufp_instance_command
@@ -308,6 +310,8 @@ PRIVACY_MODE_SWITCH = ProtectSwitchEntityDescription[Camera](
)
SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
# The public sensor object carries no led_settings, so the status light is
# the one setting that has to stay on the private API.
ProtectSwitchEntityDescription(
key="status_light",
translation_key="status_light",
@@ -320,41 +324,41 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
key="motion",
translation_key="detections_motion",
entity_category=EntityCategory.CONFIG,
ufp_value="motion_settings.is_enabled",
ufp_set_method="set_motion_status",
ufp_perm=PermRequired.WRITE,
ufp_public_value="motion_settings.is_enabled",
ufp_set_method="set_motion_status_public",
ufp_capability=SensorFeatureCapability.MOTION,
),
ProtectSwitchEntityDescription(
key="temperature",
translation_key="temperature_sensor",
entity_category=EntityCategory.CONFIG,
ufp_value="temperature_settings.is_enabled",
ufp_set_method="set_temperature_status",
ufp_perm=PermRequired.WRITE,
ufp_public_value="temperature_settings.is_enabled",
ufp_set_method="set_temperature_status_public",
ufp_capability=SensorFeatureCapability.TEMPERATURE,
),
ProtectSwitchEntityDescription(
key="humidity",
translation_key="humidity_sensor",
entity_category=EntityCategory.CONFIG,
ufp_value="humidity_settings.is_enabled",
ufp_set_method="set_humidity_status",
ufp_perm=PermRequired.WRITE,
ufp_public_value="humidity_settings.is_enabled",
ufp_set_method="set_humidity_status_public",
ufp_capability=SensorFeatureCapability.HUMIDITY,
),
ProtectSwitchEntityDescription(
key="light",
translation_key="light_sensor",
entity_category=EntityCategory.CONFIG,
ufp_value="light_settings.is_enabled",
ufp_set_method="set_light_status",
ufp_perm=PermRequired.WRITE,
ufp_public_value="light_settings.is_enabled",
ufp_set_method="set_light_status_public",
ufp_capability=SensorFeatureCapability.LIGHT,
),
ProtectSwitchEntityDescription(
key="alarm",
translation_key="alarm_sound_detection",
entity_category=EntityCategory.CONFIG,
ufp_value="alarm_settings.is_enabled",
ufp_set_method="set_alarm_status",
ufp_perm=PermRequired.WRITE,
ufp_public_value="alarm_settings.is_enabled",
ufp_set_method="set_alarm_public",
ufp_capability=SensorFeatureCapability.SMOKE,
),
)
@@ -536,6 +540,7 @@ async def async_setup_entry(
) -> None:
"""Set up sensors for UniFi Protect integration."""
data = entry.runtime_data
async_remove_unsupported_sense_entities(hass, Platform.SWITCH, data, SENSE_SWITCHES)
@callback
def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
+60 -1
View File
@@ -3,9 +3,12 @@
from unittest.mock import AsyncMock, Mock, patch
import pytest
from uiprotect.data import Sensor
from uiprotect.data.devices import Camera, Chime
from uiprotect.data.public_devices import SensorFeatureCapability
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION
from homeassistant.components.unifiprotect.button import SENSOR_BUTTONS
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION, DOMAIN
from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -15,8 +18,10 @@ from .utils import (
adopt_devices,
assert_entity_counts,
enable_entity,
ids_from_device_description,
init_entry,
remove_entities,
setup_public_sensor,
)
@@ -143,3 +148,57 @@ async def test_adopt_button_removed(
assert_entity_counts(hass, Platform.BUTTON, 4, 2)
entity = entity_registry.async_get(entity_id)
assert entity is None
CLEAR_TAMPER = next(d for d in SENSOR_BUTTONS if d.key == "clear_tamper")
async def test_button_sense_capability_creation_filter(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ufp: MockUFPFixture,
sensor_all: Sensor,
) -> None:
"""The clear-tamper button is only created for a sensor advertising tampering."""
setup_public_sensor(ufp, capabilities={SensorFeatureCapability.TEMPERATURE})
await init_entry(hass, ufp, [sensor_all])
_, entity_id = await ids_from_device_description(
hass, Platform.BUTTON, sensor_all, CLEAR_TAMPER
)
assert entity_registry.async_get(entity_id) is None
async def test_button_sense_capability_registry_cleanup(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ufp: MockUFPFixture,
sensor_all: Sensor,
) -> None:
"""A console upgrade removes the clear-tamper button when unsupported."""
stale = entity_registry.async_get_or_create(
Platform.BUTTON,
DOMAIN,
f"{sensor_all.mac}_{CLEAR_TAMPER.key}",
config_entry=ufp.entry,
)
setup_public_sensor(ufp, capabilities={SensorFeatureCapability.TEMPERATURE})
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
assert entity_registry.async_get(stale.entity_id) is None
async def test_button_sense_no_capability_map_creates_clear_tamper(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ufp: MockUFPFixture,
sensor_all: Sensor,
) -> None:
"""Without a capability map (Protect below 7.2) the button is still created."""
setup_public_sensor(ufp)
await init_entry(hass, ufp, [sensor_all])
_, entity_id = await ids_from_device_description(
hass, Platform.BUTTON, sensor_all, CLEAR_TAMPER
)
assert entity_registry.async_get(entity_id) is not None
+211 -2
View File
@@ -2,11 +2,15 @@
from unittest.mock import patch
from uiprotect.data import Camera
from uiprotect.data import Camera, Sensor
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
from homeassistant.components.unifiprotect.const import DOMAIN
from homeassistant.components.unifiprotect.migrate import (
SENSE_SETTING_MIRROR_BREAKS_IN,
async_deprecate_sense_setting_mirrors,
)
from homeassistant.const import SERVICE_RELOAD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
@@ -16,7 +20,7 @@ from homeassistant.helpers import (
)
from homeassistant.setup import async_setup_component
from .utils import MockUFPFixture, init_entry
from .utils import MockUFPFixture, init_entry, setup_public_sensor
from tests.typing import WebSocketGenerator
@@ -407,6 +411,211 @@ async def test_migrate_package_binary_sensor_removed(
)
async def test_migrate_sense_setting_mirrors_kept(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
ufp: MockUFPFixture,
sensor_all: Sensor,
) -> None:
"""The unused setting mirrors survive the deprecation without a repair."""
existing = {
(platform, key): entity_registry.async_get_or_create(
platform,
DOMAIN,
f"{sensor_all.mac}_{key}",
config_entry=ufp.entry,
)
for platform, key in (
(Platform.BINARY_SENSOR, "motion_enabled"),
(Platform.BINARY_SENSOR, "temperature"),
(Platform.BINARY_SENSOR, "humidity"),
(Platform.BINARY_SENSOR, "light"),
(Platform.BINARY_SENSOR, "alarm"),
(Platform.SENSOR, "sensitivity"),
)
}
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
for (platform, key), entity in existing.items():
assert entity_registry.async_get(entity.entity_id) is not None, (
f"{platform}.{key}"
)
assert (
issue_registry.async_get_issue(
DOMAIN, f"sense_setting_mirror_deprecated_{sensor_all.mac}_{key}"
)
is None
)
async def test_migrate_sense_setting_mirror_in_use(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
ufp: MockUFPFixture,
sensor_all: Sensor,
) -> None:
"""Deprecating a used setting mirror raises an actionable repair."""
mirror = entity_registry.async_get_or_create(
Platform.BINARY_SENSOR,
DOMAIN,
f"{sensor_all.mac}_alarm",
config_entry=ufp.entry,
)
await _load_automation(hass, mirror.entity_id)
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
assert entity_registry.async_get(mirror.entity_id) is not None
issue = issue_registry.async_get_issue(
DOMAIN, f"sense_setting_mirror_deprecated_{sensor_all.mac}_alarm"
)
assert issue is not None
assert issue.breaks_in_ha_version == SENSE_SETTING_MIRROR_BREAKS_IN
assert issue.translation_placeholders["entity_id"] == mirror.entity_id
replacement_id = entity_registry.async_get_entity_id(
Platform.SWITCH, DOMAIN, f"{sensor_all.mac}_alarm"
)
assert issue.translation_placeholders["replacement"] == replacement_id
async def test_migrate_sense_setting_mirror_repair_clears_when_unused(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
ufp: MockUFPFixture,
sensor_all: Sensor,
) -> None:
"""The deprecation repair goes away once the last usage is gone.
A removal repair has to persist, but the entity is still there, so the user
can act on this one and it must not keep nagging afterwards.
"""
mirror = entity_registry.async_get_or_create(
Platform.BINARY_SENSOR,
DOMAIN,
f"{sensor_all.mac}_alarm",
config_entry=ufp.entry,
)
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
assert entity_registry.async_get(mirror.entity_id) is not None
# The repair a previous run raised while the mirror was still in use.
issue_id = f"sense_setting_mirror_deprecated_{sensor_all.mac}_alarm"
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=False,
breaks_in_ha_version=SENSE_SETTING_MIRROR_BREAKS_IN,
severity=ir.IssueSeverity.WARNING,
translation_key="sense_setting_mirror_deprecated",
translation_placeholders={
"entity_id": mirror.entity_id,
"replacement": "switch.test_sensor_alarm_sound_detection",
"items": "* `automation.gone`\n",
},
)
assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None
async_deprecate_sense_setting_mirrors(hass, ufp.entry, ufp.api.bootstrap)
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
async def test_migrate_sense_setting_mirror_in_use_no_replacement(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
ufp: MockUFPFixture,
sensor_all: Sensor,
) -> None:
"""A device that cannot support the setting gets the no-replacement repair."""
setup_public_sensor(ufp, capabilities=set())
mirror = entity_registry.async_get_or_create(
Platform.BINARY_SENSOR,
DOMAIN,
f"{sensor_all.mac}_alarm",
config_entry=ufp.entry,
)
await _load_automation(hass, mirror.entity_id)
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
assert entity_registry.async_get(mirror.entity_id) is not None
assert (
entity_registry.async_get_entity_id(
Platform.SWITCH, DOMAIN, f"{sensor_all.mac}_alarm"
)
is None
)
issue = issue_registry.async_get_issue(
DOMAIN, f"sense_setting_mirror_deprecated_{sensor_all.mac}_alarm"
)
assert issue is not None
assert issue.translation_key == "sense_setting_mirror_deprecated_no_replacement"
assert issue.translation_placeholders["entity_id"] == mirror.entity_id
assert "replacement" not in issue.translation_placeholders
async def test_migrate_sense_setting_keys_scoped_to_sensors(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
ufp: MockUFPFixture,
doorbell: Camera,
sensor_all: Sensor,
) -> None:
"""The deprecated keys are shared with camera and light, so scoping matters.
``motion_enabled`` and ``sensitivity`` also exist on cameras and lights, so a
sensor has to be present for the migration to run at all and only the
sensor's own mirror may be deprecated.
"""
camera_entities = [
entity_registry.async_get_or_create(
platform,
DOMAIN,
f"{doorbell.mac}_{key}",
config_entry=ufp.entry,
)
for platform, key in (
(Platform.BINARY_SENSOR, "motion_enabled"),
(Platform.SENSOR, "sensitivity"),
)
]
sensor_entity = entity_registry.async_get_or_create(
Platform.BINARY_SENSOR,
DOMAIN,
f"{sensor_all.mac}_motion_enabled",
config_entry=ufp.entry,
)
# Both are used, so only the scoping decides which one gets a repair.
await _load_automation(hass, sensor_entity.entity_id)
for entity in camera_entities:
await _load_automation(hass, entity.entity_id)
await init_entry(hass, ufp, [doorbell, sensor_all], regenerate_ids=False)
assert (
issue_registry.async_get_issue(
DOMAIN, f"sense_setting_mirror_deprecated_{sensor_all.mac}_motion_enabled"
)
is not None
)
for entity in camera_entities:
assert entity_registry.async_get(entity.entity_id) is not None
assert (
issue_registry.async_get_issue(
DOMAIN, f"sense_setting_mirror_deprecated_{entity.unique_id}"
)
is None
)
async def test_migrate_package_binary_sensor_removed_in_use(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
+31 -1
View File
@@ -10,11 +10,12 @@ from uiprotect.data import (
DeviceState,
IRLEDMode,
Light,
Permission,
RingSetting,
Sensor,
)
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION, DOMAIN
from homeassistant.components.unifiprotect.number import (
CAMERA_NUMBERS,
LIGHT_NUMBERS,
@@ -439,6 +440,35 @@ async def test_number_sense_sensitivity_public_value(
assert hass.states.get(entity_id).state == "42"
async def test_number_sense_sensitivity_ignores_local_permissions(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ufp: MockUFPFixture,
sensor_all: Sensor,
) -> None:
"""A read-only local user keeps the motion sensitivity number.
It writes through the API key, so the local user's write bit must not gate it.
Its read-only mirror stays until the deprecation runs out.
"""
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])
_, entity_id = await ids_from_device_description(
hass, Platform.NUMBER, sensor_all, SENSE_NUMBERS[0]
)
assert entity_registry.async_get(entity_id) is not None
assert (
entity_registry.async_get_entity_id(
Platform.SENSOR, DOMAIN, f"{sensor_all.mac}_sensitivity"
)
is not None
)
async def test_number_sense_sensitivity_set(
hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor
) -> None:
+235 -1
View File
@@ -10,19 +10,22 @@ from uiprotect.data import (
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
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 (
@@ -47,10 +50,12 @@ from .utils import (
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 = [
@@ -871,3 +876,232 @@ async def test_switch_turn_on_not_authorized(
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
+30
View File
@@ -38,8 +38,10 @@ from uiprotect.data.public_devices import (
PublicLightModeSettings,
PublicOsdSettings,
PublicSensor,
PublicSensorAlarmSettingsRead,
PublicSensorLeakSettings,
PublicSensorMotionSettingsRead,
PublicSensorThresholdSettings,
PublicSmartDetectSettings,
PublicWirelessBatteryStatus,
PublicWirelessConnectionState,
@@ -267,6 +269,10 @@ def make_public_sensor(
is_motion_detected: bool | None = None,
motion_enabled: bool | None = None,
motion_sensitivity: int | None = None,
temperature_enabled: bool | None = None,
humidity_enabled: bool | None = None,
light_enabled: bool | None = None,
alarm_enabled: bool | None = None,
mount_type: MountType | None = None,
is_opened: bool | None = None,
is_leak_detected: bool | None = None,
@@ -332,6 +338,30 @@ def make_public_sensor(
else motion_sensitivity
),
)
public.temperature_settings = PublicSensorThresholdSettings(
is_enabled=(
sensor.temperature_settings.is_enabled
if temperature_enabled is None
else temperature_enabled
)
)
public.humidity_settings = PublicSensorThresholdSettings(
is_enabled=(
sensor.humidity_settings.is_enabled
if humidity_enabled is None
else humidity_enabled
)
)
public.light_settings = PublicSensorThresholdSettings(
is_enabled=(
sensor.light_settings.is_enabled if light_enabled is None else light_enabled
)
)
public.alarm_settings = PublicSensorAlarmSettingsRead(
is_enabled=(
sensor.alarm_settings.is_enabled if alarm_enabled is None else alarm_enabled
)
)
public.wireless_connection_state = PublicWirelessConnectionState(
battery_status=PublicWirelessBatteryStatus(
percentage=(