mirror of
https://github.com/home-assistant/core.git
synced 2026-08-29 18:25:07 -05:00
Gate the UniFi Protect sense config entities on the capability map (#177593)
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=(
|
||||
|
||||
Reference in New Issue
Block a user