Files
core/homeassistant/components/unifiprotect/migrate.py
T

352 lines
13 KiB
Python

"""UniFi Protect data migrations."""
from itertools import chain
import logging
from typing import TypedDict
from uiprotect.data import Bootstrap
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.issue_registry import IssueSeverity
from .const import DOMAIN
from .data import UFPConfigEntry
_LOGGER = logging.getLogger(__name__)
class EntityRef(TypedDict):
"""Entity ref parameter variable."""
id: str
platform: Platform
class EntityUsage(TypedDict):
"""Entity usages response variable."""
automations: dict[str, list[str]]
scripts: dict[str, list[str]]
@callback
def check_if_used(
hass: HomeAssistant, entry: UFPConfigEntry, entities: dict[str, EntityRef]
) -> dict[str, EntityUsage]:
"""Check for usages of entities and return them."""
entity_registry = er.async_get(hass)
refs: dict[str, EntityUsage] = {
ref: {"automations": {}, "scripts": {}} for ref in entities
}
for entity in er.async_entries_for_config_entry(entity_registry, entry.entry_id):
for ref_id, ref in entities.items():
if (
entity.domain == ref["platform"]
and entity.disabled_by is None
and ref["id"] in entity.unique_id
):
entity_automations = automations_with_entity(hass, entity.entity_id)
entity_scripts = scripts_with_entity(hass, entity.entity_id)
if entity_automations:
refs[ref_id]["automations"][entity.entity_id] = entity_automations
if entity_scripts:
refs[ref_id]["scripts"][entity.entity_id] = entity_scripts
return refs
@callback
def create_repair_if_used(
hass: HomeAssistant,
entry: UFPConfigEntry,
breaks_in: str,
entities: dict[str, EntityRef],
) -> None:
"""Create repairs for used entities that are deprecated."""
usages = check_if_used(hass, entry, entities)
for ref_id, refs in usages.items():
issue_id = f"deprecate_{ref_id}"
automations = refs["automations"]
scripts = refs["scripts"]
if automations or scripts:
items = sorted(
set(chain.from_iterable(chain(automations.values(), scripts.values())))
)
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=False,
breaks_in_ha_version=breaks_in,
severity=IssueSeverity.WARNING,
translation_key=issue_id,
translation_placeholders={
"items": "* `" + "`\n* `".join(items) + "`\n"
},
)
else:
_LOGGER.debug("No found usages of %s", ref_id)
ir.async_delete_issue(hass, DOMAIN, issue_id)
async def async_migrate_data(hass: HomeAssistant, entry: UFPConfigEntry) -> None:
"""Run all valid UniFi Protect data migrations.
Every migration operates purely on the entity/device registries, so this
runs in both connection modes (no bootstrap required).
"""
_LOGGER.debug("Start Migrate: async_deprecate_hdr")
async_deprecate_hdr(hass, entry)
_LOGGER.debug("Completed Migrate: async_deprecate_hdr")
_LOGGER.debug("Start Migrate: async_remove_aiport_devices")
async_remove_aiport_devices(hass, entry)
_LOGGER.debug("Completed Migrate: async_remove_aiport_devices")
_LOGGER.debug("Start Migrate: async_migrate_insecure_cameras")
async_migrate_insecure_cameras(hass, entry)
_LOGGER.debug("Completed Migrate: async_migrate_insecure_cameras")
_LOGGER.debug("Start Migrate: async_remove_package_binary_sensor")
async_remove_package_binary_sensor(hass, entry)
_LOGGER.debug("Completed Migrate: async_remove_package_binary_sensor")
# Device type (``ProtectAdoptableDeviceModel.type``) reported by AI Ports. Matched
# in the registry so cleanup does not depend on the bundled library still exposing
# the AI Port model.
_AIPORT_DEVICE_TYPE = "AI Port"
@callback
def async_remove_aiport_devices(hass: HomeAssistant, entry: UFPConfigEntry) -> None:
"""Remove AI Port devices and their diagnostic-only entities.
AI Ports only ever exposed diagnostic sensors (no automation-relevant
functionality) and behave transparently, extending the camera they back.
They have no public API representation, so support is dropped. Devices are
matched from the registry (by device type) rather than the live bootstrap, so
cleanup works even once the library drops the AI Port model.
Added in 2026.7.0
"""
device_registry = dr.async_get(hass)
for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
if device.model_id != _AIPORT_DEVICE_TYPE:
continue
device_registry.async_remove_device(device.id)
@callback
def async_migrate_insecure_cameras(hass: HomeAssistant, entry: UFPConfigEntry) -> None:
"""Migrate the legacy plain-RTSP "(insecure)" camera entities.
Streams now come from the public API, which is RTSPS-only, so the old
``{mac}_{channel}_insecure`` camera entities no longer exist. Redirect each
to its secure unique_id (``{mac}_{channel}``) so its history/customizations
carry over to the public stream; if the secure entity already exists, drop
the redundant insecure one (raising a repair first if it is still used).
Added in 2026.7.0
"""
registry = er.async_get(hass)
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
if entity.domain != Platform.CAMERA or not entity.unique_id.endswith(
"_insecure"
):
continue
secure_unique_id = entity.unique_id.removesuffix("_insecure")
secure_entity_id = registry.async_get_entity_id(
Platform.CAMERA, DOMAIN, secure_unique_id
)
if secure_entity_id is None:
registry.async_update_entity(
entity.entity_id, new_unique_id=secure_unique_id
)
continue
_async_repair_if_used(
hass,
entity,
f"insecure_camera_removed_{entity.unique_id}",
"insecure_camera_removed",
{"replacement": secure_entity_id},
)
registry.async_remove(entity.entity_id)
@callback
def _async_repair_if_used(
hass: HomeAssistant,
entity: er.RegistryEntry,
issue_id: str,
translation_key: str,
placeholders: dict[str, str] | None = None,
breaks_in: str | None = None,
) -> None:
"""Raise a repair for an entity that is going away and is still in use.
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
items = sorted(
set(automations_with_entity(hass, entity.entity_id))
| 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=breaks_in is None,
breaks_in_ha_version=breaks_in,
severity=IssueSeverity.WARNING,
translation_key=translation_key,
translation_placeholders={
"entity_id": entity.entity_id,
"items": "* `" + "`\n* `".join(items) + "`\n",
**(placeholders or {}),
},
)
@callback
def async_remove_package_binary_sensor(
hass: HomeAssistant, entry: UFPConfigEntry
) -> None:
"""Remove the package smart-detect binary sensor.
Package detection is a momentary smart-detect event, so it now surfaces as a
package event entity instead of a sustained binary sensor. The old
``{mac}_smart_obj_package`` binary sensors no longer exist; remove each stale
entry, raising a repair first if a still-enabled one is referenced by an
automation or script.
Added in 2026.7.0
"""
registry = er.async_get(hass)
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
if entity.domain != Platform.BINARY_SENSOR or not entity.unique_id.endswith(
"_smart_obj_package"
):
continue
_async_repair_if_used(
hass,
entity,
f"package_binary_sensor_removed_{entity.unique_id}",
"package_binary_sensor_removed",
)
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.
UniFi Protect v3.0.22 changed how HDR works so it is
no longer a simple on/off toggle. There is Always On,
Always Off and Auto. So it has been migrated to a
select. The old switch is now deprecated.
Added in 2024.4.0
"""
create_repair_if_used(
hass,
entry,
"2024.10.0",
{"hdr_switch": {"id": "hdr_mode", "platform": Platform.SWITCH}},
)