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
+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=(