mirror of
https://github.com/home-assistant/core.git
synced 2026-09-08 06:31:35 +01:00
352 lines
13 KiB
Python
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}},
|
|
)
|