574 lines
19 KiB
Python
574 lines
19 KiB
Python
"""Functions used to migrate unique IDs for Z-Wave JS entities."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
import logging
|
|
from typing import TypedDict, cast
|
|
|
|
from zwave_js_server.client import Client as ZwaveClient
|
|
from zwave_js_server.model.value import Value as ZwaveValue
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import STATE_UNAVAILABLE
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.helpers.device_registry import (
|
|
DeviceEntry,
|
|
async_get as async_get_device_registry,
|
|
)
|
|
from homeassistant.helpers.entity_registry import (
|
|
EntityRegistry,
|
|
RegistryEntry,
|
|
async_entries_for_device,
|
|
async_get as async_get_entity_registry,
|
|
)
|
|
from homeassistant.helpers.singleton import singleton
|
|
from homeassistant.helpers.storage import Store
|
|
|
|
from .const import DOMAIN
|
|
from .discovery import ZwaveDiscoveryInfo
|
|
from .helpers import get_device_id, get_unique_id
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
LEGACY_ZWAVE_MIGRATION = f"{DOMAIN}_legacy_zwave_migration"
|
|
MIGRATED = "migrated"
|
|
STORAGE_WRITE_DELAY = 30
|
|
STORAGE_KEY = f"{DOMAIN}.legacy_zwave_migration"
|
|
STORAGE_VERSION = 1
|
|
|
|
NOTIFICATION_CC_LABEL_TO_PROPERTY_NAME = {
|
|
"Smoke": "Smoke Alarm",
|
|
"Carbon Monoxide": "CO Alarm",
|
|
"Carbon Dioxide": "CO2 Alarm",
|
|
"Heat": "Heat Alarm",
|
|
"Flood": "Water Alarm",
|
|
"Access Control": "Access Control",
|
|
"Burglar": "Home Security",
|
|
"Power Management": "Power Management",
|
|
"System": "System",
|
|
"Emergency": "Siren",
|
|
"Clock": "Clock",
|
|
"Appliance": "Appliance",
|
|
"HomeHealth": "Home Health",
|
|
}
|
|
|
|
SENSOR_MULTILEVEL_CC_LABEL_TO_PROPERTY_NAME = {
|
|
"Temperature": "Air temperature",
|
|
"General": "General purpose",
|
|
"Luminance": "Illuminance",
|
|
"Power": "Power",
|
|
"Relative Humidity": "Humidity",
|
|
"Velocity": "Velocity",
|
|
"Direction": "Direction",
|
|
"Atmospheric Pressure": "Atmospheric pressure",
|
|
"Barometric Pressure": "Barometric pressure",
|
|
"Solar Radiation": "Solar radiation",
|
|
"Dew Point": "Dew point",
|
|
"Rain Rate": "Rain rate",
|
|
"Tide Level": "Tide level",
|
|
"Weight": "Weight",
|
|
"Voltage": "Voltage",
|
|
"Current": "Current",
|
|
"CO2 Level": "Carbon dioxide (CO₂) level",
|
|
"Air Flow": "Air flow",
|
|
"Tank Capacity": "Tank capacity",
|
|
"Distance": "Distance",
|
|
"Angle Position": "Angle position",
|
|
"Rotation": "Rotation",
|
|
"Water Temperature": "Water temperature",
|
|
"Soil Temperature": "Soil temperature",
|
|
"Seismic Intensity": "Seismic Intensity",
|
|
"Seismic Magnitude": "Seismic magnitude",
|
|
"Ultraviolet": "Ultraviolet",
|
|
"Electrical Resistivity": "Electrical resistivity",
|
|
"Electrical Conductivity": "Electrical conductivity",
|
|
"Loudness": "Loudness",
|
|
"Moisture": "Moisture",
|
|
}
|
|
|
|
CC_ID_LABEL_TO_PROPERTY = {
|
|
49: SENSOR_MULTILEVEL_CC_LABEL_TO_PROPERTY_NAME,
|
|
113: NOTIFICATION_CC_LABEL_TO_PROPERTY_NAME,
|
|
}
|
|
|
|
|
|
class ZWaveMigrationData(TypedDict):
|
|
"""Represent the Z-Wave migration data dict."""
|
|
|
|
node_id: int
|
|
node_instance: int
|
|
command_class: int
|
|
command_class_label: str
|
|
value_index: int
|
|
device_id: str
|
|
domain: str
|
|
entity_id: str
|
|
unique_id: str
|
|
unit_of_measurement: str | None
|
|
|
|
|
|
class ZWaveJSMigrationData(TypedDict):
|
|
"""Represent the Z-Wave JS migration data dict."""
|
|
|
|
node_id: int
|
|
endpoint_index: int
|
|
command_class: int
|
|
value_property_name: str
|
|
value_property_key_name: str | None
|
|
value_id: str
|
|
device_id: str
|
|
domain: str
|
|
entity_id: str
|
|
unique_id: str
|
|
unit_of_measurement: str | None
|
|
|
|
|
|
@dataclass
|
|
class LegacyZWaveMappedData:
|
|
"""Represent the mapped data between Z-Wave and Z-Wave JS."""
|
|
|
|
entity_entries: dict[str, ZWaveMigrationData] = field(default_factory=dict)
|
|
device_entries: dict[str, str] = field(default_factory=dict)
|
|
|
|
|
|
async def async_add_migration_entity_value(
|
|
hass: HomeAssistant,
|
|
config_entry: ConfigEntry,
|
|
entity_id: str,
|
|
discovery_info: ZwaveDiscoveryInfo,
|
|
) -> None:
|
|
"""Add Z-Wave JS entity value for legacy Z-Wave migration."""
|
|
migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass)
|
|
migration_handler.add_entity_value(config_entry, entity_id, discovery_info)
|
|
|
|
|
|
async def async_get_migration_data(
|
|
hass: HomeAssistant, config_entry: ConfigEntry
|
|
) -> dict[str, ZWaveJSMigrationData]:
|
|
"""Return Z-Wave JS migration data."""
|
|
migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass)
|
|
return await migration_handler.get_data(config_entry)
|
|
|
|
|
|
@singleton(LEGACY_ZWAVE_MIGRATION)
|
|
async def get_legacy_zwave_migration(hass: HomeAssistant) -> LegacyZWaveMigration:
|
|
"""Return legacy Z-Wave migration handler."""
|
|
migration_handler = LegacyZWaveMigration(hass)
|
|
await migration_handler.load_data()
|
|
return migration_handler
|
|
|
|
|
|
class LegacyZWaveMigration:
|
|
"""Handle the migration from zwave to zwave_js."""
|
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
|
"""Set up migration instance."""
|
|
self._hass = hass
|
|
self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
|
self._data: dict[str, dict[str, ZWaveJSMigrationData]] = {}
|
|
|
|
async def load_data(self) -> None:
|
|
"""Load Z-Wave JS migration data."""
|
|
stored = cast(dict, await self._store.async_load())
|
|
if stored:
|
|
self._data = stored
|
|
|
|
@callback
|
|
def save_data(
|
|
self, config_entry_id: str, entity_id: str, data: ZWaveJSMigrationData
|
|
) -> None:
|
|
"""Save Z-Wave JS migration data."""
|
|
if config_entry_id not in self._data:
|
|
self._data[config_entry_id] = {}
|
|
self._data[config_entry_id][entity_id] = data
|
|
self._store.async_delay_save(self._data_to_save, STORAGE_WRITE_DELAY)
|
|
|
|
@callback
|
|
def _data_to_save(self) -> dict[str, dict[str, ZWaveJSMigrationData]]:
|
|
"""Return data to save."""
|
|
return self._data
|
|
|
|
@callback
|
|
def add_entity_value(
|
|
self,
|
|
config_entry: ConfigEntry,
|
|
entity_id: str,
|
|
discovery_info: ZwaveDiscoveryInfo,
|
|
) -> None:
|
|
"""Add info for one entity and Z-Wave JS value."""
|
|
ent_reg = async_get_entity_registry(self._hass)
|
|
dev_reg = async_get_device_registry(self._hass)
|
|
|
|
node = discovery_info.node
|
|
primary_value = discovery_info.primary_value
|
|
entity_entry = ent_reg.async_get(entity_id)
|
|
assert entity_entry
|
|
device_identifier = get_device_id(node.client, node)
|
|
device_entry = dev_reg.async_get_device({device_identifier}, set())
|
|
assert device_entry
|
|
|
|
# Normalize unit of measurement.
|
|
if unit := entity_entry.unit_of_measurement:
|
|
unit = unit.lower()
|
|
if unit == "":
|
|
unit = None
|
|
|
|
data: ZWaveJSMigrationData = {
|
|
"node_id": node.node_id,
|
|
"endpoint_index": node.index,
|
|
"command_class": primary_value.command_class,
|
|
"value_property_name": primary_value.property_name,
|
|
"value_property_key_name": primary_value.property_key_name,
|
|
"value_id": primary_value.value_id,
|
|
"device_id": device_entry.id,
|
|
"domain": entity_entry.domain,
|
|
"entity_id": entity_id,
|
|
"unique_id": entity_entry.unique_id,
|
|
"unit_of_measurement": unit,
|
|
}
|
|
|
|
self.save_data(config_entry.entry_id, entity_id, data)
|
|
|
|
async def get_data(
|
|
self, config_entry: ConfigEntry
|
|
) -> dict[str, ZWaveJSMigrationData]:
|
|
"""Return Z-Wave JS migration data for a config entry."""
|
|
await self.load_data()
|
|
data = self._data.get(config_entry.entry_id)
|
|
return data or {}
|
|
|
|
|
|
@callback
|
|
def async_map_legacy_zwave_values(
|
|
zwave_data: dict[str, ZWaveMigrationData],
|
|
zwave_js_data: dict[str, ZWaveJSMigrationData],
|
|
) -> LegacyZWaveMappedData:
|
|
"""Map Z-Wave node values onto Z-Wave JS node values."""
|
|
migration_map = LegacyZWaveMappedData()
|
|
zwave_proc_data: dict[
|
|
tuple[int, int, int, str, str | None, str | None],
|
|
ZWaveMigrationData | None,
|
|
] = {}
|
|
zwave_js_proc_data: dict[
|
|
tuple[int, int, int, str, str | None, str | None],
|
|
ZWaveJSMigrationData | None,
|
|
] = {}
|
|
|
|
for zwave_item in zwave_data.values():
|
|
zwave_js_property_name = CC_ID_LABEL_TO_PROPERTY.get(
|
|
zwave_item["command_class"], {}
|
|
).get(zwave_item["command_class_label"])
|
|
item_id = (
|
|
zwave_item["node_id"],
|
|
zwave_item["command_class"],
|
|
zwave_item["node_instance"] - 1,
|
|
zwave_item["domain"],
|
|
zwave_item["unit_of_measurement"],
|
|
zwave_js_property_name,
|
|
)
|
|
|
|
# Filter out duplicates that are not resolvable.
|
|
if item_id in zwave_proc_data:
|
|
zwave_proc_data[item_id] = None
|
|
continue
|
|
|
|
zwave_proc_data[item_id] = zwave_item
|
|
|
|
for zwave_js_item in zwave_js_data.values():
|
|
# Only identify with property name if there is a command class label map.
|
|
if zwave_js_item["command_class"] in CC_ID_LABEL_TO_PROPERTY:
|
|
zwave_js_property_name = zwave_js_item["value_property_name"]
|
|
else:
|
|
zwave_js_property_name = None
|
|
item_id = (
|
|
zwave_js_item["node_id"],
|
|
zwave_js_item["command_class"],
|
|
zwave_js_item["endpoint_index"],
|
|
zwave_js_item["domain"],
|
|
zwave_js_item["unit_of_measurement"],
|
|
zwave_js_property_name,
|
|
)
|
|
|
|
# Filter out duplicates that are not resolvable.
|
|
if item_id in zwave_js_proc_data:
|
|
zwave_js_proc_data[item_id] = None
|
|
continue
|
|
|
|
zwave_js_proc_data[item_id] = zwave_js_item
|
|
|
|
for item_id, zwave_entry in zwave_proc_data.items():
|
|
zwave_js_entry = zwave_js_proc_data.pop(item_id, None)
|
|
|
|
if zwave_entry is None or zwave_js_entry is None:
|
|
continue
|
|
|
|
migration_map.entity_entries[zwave_js_entry["entity_id"]] = zwave_entry
|
|
migration_map.device_entries[zwave_js_entry["device_id"]] = zwave_entry[
|
|
"device_id"
|
|
]
|
|
|
|
return migration_map
|
|
|
|
|
|
async def async_migrate_legacy_zwave(
|
|
hass: HomeAssistant,
|
|
zwave_config_entry: ConfigEntry,
|
|
zwave_js_config_entry: ConfigEntry,
|
|
migration_map: LegacyZWaveMappedData,
|
|
) -> None:
|
|
"""Perform Z-Wave to Z-Wave JS migration."""
|
|
dev_reg = async_get_device_registry(hass)
|
|
for zwave_js_device_id, zwave_device_id in migration_map.device_entries.items():
|
|
zwave_device_entry = dev_reg.async_get(zwave_device_id)
|
|
if not zwave_device_entry:
|
|
continue
|
|
dev_reg.async_update_device(
|
|
zwave_js_device_id,
|
|
area_id=zwave_device_entry.area_id,
|
|
name_by_user=zwave_device_entry.name_by_user,
|
|
)
|
|
|
|
ent_reg = async_get_entity_registry(hass)
|
|
for zwave_js_entity_id, zwave_entry in migration_map.entity_entries.items():
|
|
zwave_entity_id = zwave_entry["entity_id"]
|
|
if not (entity_entry := ent_reg.async_get(zwave_entity_id)):
|
|
continue
|
|
ent_reg.async_remove(zwave_entity_id)
|
|
ent_reg.async_update_entity(
|
|
zwave_js_entity_id,
|
|
new_entity_id=entity_entry.entity_id,
|
|
name=entity_entry.name,
|
|
icon=entity_entry.icon,
|
|
)
|
|
|
|
await hass.config_entries.async_remove(zwave_config_entry.entry_id)
|
|
|
|
updates = {
|
|
**zwave_js_config_entry.data,
|
|
MIGRATED: True,
|
|
}
|
|
hass.config_entries.async_update_entry(zwave_js_config_entry, data=updates)
|
|
|
|
|
|
@dataclass
|
|
class ValueID:
|
|
"""Class to represent a Value ID."""
|
|
|
|
command_class: str
|
|
endpoint: str
|
|
property_: str
|
|
property_key: str | None = None
|
|
|
|
@staticmethod
|
|
def from_unique_id(unique_id: str) -> ValueID:
|
|
"""
|
|
Get a ValueID from a unique ID.
|
|
|
|
This also works for Notification CC Binary Sensors which have their own unique ID
|
|
format.
|
|
"""
|
|
return ValueID.from_string_id(unique_id.split(".")[1])
|
|
|
|
@staticmethod
|
|
def from_string_id(value_id_str: str) -> ValueID:
|
|
"""Get a ValueID from a string representation of the value ID."""
|
|
parts = value_id_str.split("-")
|
|
property_key = parts[4] if len(parts) > 4 else None
|
|
return ValueID(parts[1], parts[2], parts[3], property_key=property_key)
|
|
|
|
def is_same_value_different_endpoints(self, other: ValueID) -> bool:
|
|
"""Return whether two value IDs are the same excluding endpoint."""
|
|
return (
|
|
self.command_class == other.command_class
|
|
and self.property_ == other.property_
|
|
and self.property_key == other.property_key
|
|
and self.endpoint != other.endpoint
|
|
)
|
|
|
|
|
|
@callback
|
|
def async_migrate_old_entity(
|
|
hass: HomeAssistant,
|
|
ent_reg: EntityRegistry,
|
|
registered_unique_ids: set[str],
|
|
platform: str,
|
|
device: DeviceEntry,
|
|
unique_id: str,
|
|
) -> None:
|
|
"""Migrate existing entity if current one can't be found and an old one exists."""
|
|
# If we can find an existing entity with this unique ID, there's nothing to migrate
|
|
if ent_reg.async_get_entity_id(platform, DOMAIN, unique_id):
|
|
return
|
|
|
|
value_id = ValueID.from_unique_id(unique_id)
|
|
|
|
# Look for existing entities in the registry that could be the same value but on
|
|
# a different endpoint
|
|
existing_entity_entries: list[RegistryEntry] = []
|
|
for entry in async_entries_for_device(ent_reg, device.id):
|
|
# If entity is not in the domain for this discovery info or entity has already
|
|
# been processed, skip it
|
|
if entry.domain != platform or entry.unique_id in registered_unique_ids:
|
|
continue
|
|
|
|
try:
|
|
old_ent_value_id = ValueID.from_unique_id(entry.unique_id)
|
|
# Skip non value ID based unique ID's (e.g. node status sensor)
|
|
except IndexError:
|
|
continue
|
|
|
|
if value_id.is_same_value_different_endpoints(old_ent_value_id):
|
|
existing_entity_entries.append(entry)
|
|
# We can return early if we get more than one result
|
|
if len(existing_entity_entries) > 1:
|
|
return
|
|
|
|
# If we couldn't find any results, return early
|
|
if not existing_entity_entries:
|
|
return
|
|
|
|
entry = existing_entity_entries[0]
|
|
state = hass.states.get(entry.entity_id)
|
|
|
|
if not state or state.state == STATE_UNAVAILABLE:
|
|
async_migrate_unique_id(ent_reg, platform, entry.unique_id, unique_id)
|
|
|
|
|
|
@callback
|
|
def async_migrate_unique_id(
|
|
ent_reg: EntityRegistry, platform: str, old_unique_id: str, new_unique_id: str
|
|
) -> None:
|
|
"""Check if entity with old unique ID exists, and if so migrate it to new ID."""
|
|
if entity_id := ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id):
|
|
_LOGGER.debug(
|
|
"Migrating entity %s from old unique ID '%s' to new unique ID '%s'",
|
|
entity_id,
|
|
old_unique_id,
|
|
new_unique_id,
|
|
)
|
|
try:
|
|
ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id)
|
|
except ValueError:
|
|
_LOGGER.debug(
|
|
(
|
|
"Entity %s can't be migrated because the unique ID is taken; "
|
|
"Cleaning it up since it is likely no longer valid"
|
|
),
|
|
entity_id,
|
|
)
|
|
ent_reg.async_remove(entity_id)
|
|
|
|
|
|
@callback
|
|
def async_migrate_discovered_value(
|
|
hass: HomeAssistant,
|
|
ent_reg: EntityRegistry,
|
|
registered_unique_ids: set[str],
|
|
device: DeviceEntry,
|
|
client: ZwaveClient,
|
|
disc_info: ZwaveDiscoveryInfo,
|
|
) -> None:
|
|
"""Migrate unique ID for entity/entities tied to discovered value."""
|
|
|
|
new_unique_id = get_unique_id(
|
|
client.driver.controller.home_id,
|
|
disc_info.primary_value.value_id,
|
|
)
|
|
|
|
# On reinterviews, there is no point in going through this logic again for already
|
|
# discovered values
|
|
if new_unique_id in registered_unique_ids:
|
|
return
|
|
|
|
# Migration logic was added in 2021.3 to handle a breaking change to the value_id
|
|
# format. Some time in the future, the logic to migrate unique IDs can be removed.
|
|
|
|
# 2021.2.*, 2021.3.0b0, and 2021.3.0 formats
|
|
old_unique_ids = [
|
|
get_unique_id(
|
|
client.driver.controller.home_id,
|
|
value_id,
|
|
)
|
|
for value_id in get_old_value_ids(disc_info.primary_value)
|
|
]
|
|
|
|
if (
|
|
disc_info.platform == "binary_sensor"
|
|
and disc_info.platform_hint == "notification"
|
|
):
|
|
for state_key in disc_info.primary_value.metadata.states:
|
|
# ignore idle key (0)
|
|
if state_key == "0":
|
|
continue
|
|
|
|
new_bin_sensor_unique_id = f"{new_unique_id}.{state_key}"
|
|
|
|
# On reinterviews, there is no point in going through this logic again
|
|
# for already discovered values
|
|
if new_bin_sensor_unique_id in registered_unique_ids:
|
|
continue
|
|
|
|
# Unique ID migration
|
|
for old_unique_id in old_unique_ids:
|
|
async_migrate_unique_id(
|
|
ent_reg,
|
|
disc_info.platform,
|
|
f"{old_unique_id}.{state_key}",
|
|
new_bin_sensor_unique_id,
|
|
)
|
|
|
|
# Migrate entities in case upstream changes cause endpoint change
|
|
async_migrate_old_entity(
|
|
hass,
|
|
ent_reg,
|
|
registered_unique_ids,
|
|
disc_info.platform,
|
|
device,
|
|
new_bin_sensor_unique_id,
|
|
)
|
|
registered_unique_ids.add(new_bin_sensor_unique_id)
|
|
|
|
# Once we've iterated through all state keys, we are done
|
|
return
|
|
|
|
# Unique ID migration
|
|
for old_unique_id in old_unique_ids:
|
|
async_migrate_unique_id(
|
|
ent_reg, disc_info.platform, old_unique_id, new_unique_id
|
|
)
|
|
|
|
# Migrate entities in case upstream changes cause endpoint change
|
|
async_migrate_old_entity(
|
|
hass, ent_reg, registered_unique_ids, disc_info.platform, device, new_unique_id
|
|
)
|
|
registered_unique_ids.add(new_unique_id)
|
|
|
|
|
|
@callback
|
|
def get_old_value_ids(value: ZwaveValue) -> list[str]:
|
|
"""Get old value IDs so we can migrate entity unique ID."""
|
|
value_ids = []
|
|
|
|
# Pre 2021.3.0 value ID
|
|
command_class = value.command_class
|
|
endpoint = value.endpoint or "00"
|
|
property_ = value.property_
|
|
property_key_name = value.property_key_name or "00"
|
|
value_ids.append(
|
|
f"{value.node.node_id}.{value.node.node_id}-{command_class}-{endpoint}-"
|
|
f"{property_}-{property_key_name}"
|
|
)
|
|
|
|
endpoint = "00" if value.endpoint is None else value.endpoint
|
|
property_key = "00" if value.property_key is None else value.property_key
|
|
property_key_name = value.property_key_name or "00"
|
|
|
|
value_id = (
|
|
f"{value.node.node_id}-{command_class}-{endpoint}-"
|
|
f"{property_}-{property_key}-{property_key_name}"
|
|
)
|
|
# 2021.3.0b0 and 2021.3.0 value IDs
|
|
value_ids.extend([f"{value.node.node_id}.{value_id}", value_id])
|
|
|
|
return value_ids
|