diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index ed76da0f2811..9a4d7b2047cb 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -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)) diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 72379d431f1e..8b8c6f24f457 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -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, diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index d2a94eb53b38..58a5cf4b47ca 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -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. diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 2f7cf224e209..3499bfe0b090 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -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, ), ) diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 76c1ba0834dc..80ffaa051c6d 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -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": { diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 310ad7b963bf..3aeedcb05608 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -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: diff --git a/tests/components/unifiprotect/test_button.py b/tests/components/unifiprotect/test_button.py index db33466429e5..027f559f43d3 100644 --- a/tests/components/unifiprotect/test_button.py +++ b/tests/components/unifiprotect/test_button.py @@ -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 diff --git a/tests/components/unifiprotect/test_migrate.py b/tests/components/unifiprotect/test_migrate.py index 90f0ef96cb2d..1ad27c2c6400 100644 --- a/tests/components/unifiprotect/test_migrate.py +++ b/tests/components/unifiprotect/test_migrate.py @@ -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, diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 358413c4d995..a7e265a6ee13 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -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: diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index fa0d054980db..f7f822016d3b 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -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 diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index afeccb5cef83..7bceed289a70 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -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=(