HA unique_id migration — legacy → loom canonical schema¶
Who this page is for
Authors of the homematicip_local / py-openccu-loom-client drop-in client. Operators do not need this page; it specifies a one-time client-side registry migration. Background: identity & scoping.
Status: Ready to implement (client-side). The daemon-side prerequisite has landed — the value-bearing push payloads now carry the optional canonical unique_id (P6, see ha-drop-in-identity-and-scoping.md), so the client can both consume the key and verify its own rebuild. This document specifies the one-time HA registry migration the client runs. Audience: homematicip_local / py-openccu-loom-client authors. Related: ha-drop-in-identity-and-scoping.md, by_design.md → BD-Identity-RoutingKeyNamespaces.
Why a migration¶
Home Assistant stores each entity under a unique_id in its entity registry; that key carries the entity's history, customisations, area assignment and entity_id. The loom canonical schema differs from the legacy aiohomematic routing key (it adds a constant loom_ namespace and, for hub-level entities, swaps the HA entry_id[-10:] prefix for the CCU serial). A naive cutover would orphan every entity.
HA supports rewriting registry keys without data loss via homeassistant.helpers.entity_registry.async_migrate_entries. The integration runs a one-time, deterministic old → new rewrite on setup; history and customisations follow the key. This document specifies that mapping so it stays identical across client versions.
The canonical schema (target)¶
Every loom unique_id is:
where <routing-key> is the shared cross-backend routing key (mirrored on the daemon side in internal/routingkey, locked by a golden-fixture contract test). The leading central_id slot of the routing key is:
- empty for normal devices — the device serial (e.g.
VCU1234567) is already globally unique, so no prefix is needed; - the CCU serial, last 10 characters, lower-cased for the address classes whose addresses repeat across CCUs: hub roots (
sysvar/program/install_mode), internal addresses (INT000*), and virtual-remote channels (BidCoS-*,HmIP-RCV-1,VCUvirtual-remote range).
Hub data-point names are slugged with the shared hub_slug rule (python-slugify defaults: Unicode transliteration, dash separator, lower-cased) before they enter the routing key.
Examples¶
CCU serial 3014F711A0001234 → serial suffix 11a0001234.
| Entity | Legacy HA unique_id | New loom unique_id |
|---|---|---|
Device VCU1234567:1 STATE | vcu1234567_1_state | loom_vcu1234567_1_state |
Button event VCU1234567:1 PRESS_SHORT | event_vcu1234567_1_press_short | loom_event_vcu1234567_1_press_short |
Sysvar Außen Temperatur | a1b2c3d4e5_sysvar_aussen-temperatur | loom_11a0001234_sysvar_aussen-temperatur |
Program My Prog | a1b2c3d4e5_program_my-prog | loom_11a0001234_program_my-prog |
Internal INT0001234:1 LEVEL | a1b2c3d4e5_int0001234_1_level | loom_11a0001234_int0001234_1_level |
Virtual remote BidCoS-RF:1 PRESS_SHORT | a1b2c3d4e5_bidcos_rf_1_press_short | loom_11a0001234_bidcos_rf_1_press_short |
a1b2c3d4e5 above is the legacy entry_id[-10:]; 11a0001234 is the new serial[-10:].
The mapping¶
Two inputs the client already has per config entry:
entry_suffix = entry.entry_id[-10:]— the legacy hub prefix.serial_suffix = serial[-10:].lower()— the CCU serial, last 10 characters. The serial is the config entry's own HAunique_id(the entry identifies its CCU by serial); it is also available from the daemon viaGET /system/ccu(SystemCCUEntry.serial).
The rewrite is purely string-level, so it does not depend on the device tree being loaded:
def migrate_unique_id(old: str, *, entry_suffix: str, serial_suffix: str) -> str | None:
# Idempotent: already migrated.
if old.startswith("loom_"):
return None
# Hub / internal / virtual-remote entities carried the entry_id[-10:]
# prefix; swap it for the CCU serial suffix.
prefix = f"{entry_suffix}_"
if old.startswith(prefix):
return f"loom_{serial_suffix}_{old[len(prefix):]}"
# Everything else (devices, button events) had no central prefix.
return f"loom_{old}"
Wired into HA setup:
from homeassistant.helpers import entity_registry as er
async def _async_migrate_unique_ids(hass, entry):
entry_suffix = entry.entry_id[-10:]
serial_suffix = entry.unique_id[-10:].lower() # entry.unique_id == CCU serial
@callback
def _migrator(reg_entry: er.RegistryEntry) -> dict | None:
new = migrate_unique_id(
reg_entry.unique_id,
entry_suffix=entry_suffix,
serial_suffix=serial_suffix,
)
if new is None or new == reg_entry.unique_id:
return None
return {"new_unique_id": new}
await er.async_migrate_entries(hass, entry.entry_id, _migrator)
Run it once, early in async_setup_entry, before any entity is added for that entry, so newly-created entities already see the migrated keys.
Rules & edge cases¶
- Idempotency. A second run is a no-op: keys already starting with
loom_are skipped. Safe to call on every setup. - Per-entry scope.
async_migrate_entriesis scoped to oneentry_id, so only that CCU's entities are touched. Multi-CCU setups migrate each entry with its ownserial_suffix. - Target collisions. If two legacy keys map to the same new key (e.g. two sysvars whose names slug identically — already a property of the shared
hub_slugrule), HA's registry refuses the second rename because the targetunique_idalready exists; the entity keeps its old key. Log these; they indicate a pre-existing slug collision on the CCU side, not a migration bug. - Forced-sensor suffix. Daemon-side data points that carry the
_sensordisambiguation suffix already include it in the routing key, so it survives theloom_prepend unchanged — no special handling. - Serial availability. The migration uses
entry.unique_id(the serial) and never needs a live CCU connection, so it is safe to run before the first connect. (The daemon, by contrast, only learns the serial post-connect — but it builds hub keys only for entities that are themselves discovered post-connect, so the two sides stay consistent.) - Verification. The daemon now carries the
unique_idfield on its value-bearing payloads (see Recommended daemon change below, now landed), so a device entity's migratedunique_idshould equal that field verbatim (devices carry no central prefix, so daemon key == HA key) — a cheap built-in drift check. The client still owns the HA value and keeps the rebuild path as a fallback for payloads that omit the field (e.g. before the CCU serial is known).
Relationship to the daemon schema¶
The daemon runs the same routing-key contract on the Go side (internal/routingkey) and uses the canonical loom_ key for MQTT discovery. The value-bearing WS payloads now carry the canonical unique_id as an optional field (P6) — built from the same raw inputs (device_address + channel → address, parameter, and the hub name) at the publish boundary, which holds the central → serial mapping. The client consumes payload.unique_id when present and rebuilds the key from the raw fields when it is absent. Because both sides run the same contract, the rebuilt key is bit-identical to the daemon's.
The namespace split (why three id producers exist and which is the HA key) is catalogued in by_design.md → BD-Identity-RoutingKeyNamespaces.
Recommended daemon change: carry unique_id on the value-bearing payloads (landed, P6)¶
Rebuilding works, but it re-implements the contract on every consumer and leaves a silent-drift risk: a client that rebuilds slightly wrong routes to the wrong entity (or none), and nothing catches it. Carrying the canonical key on the payload removes that — the client consumes it directly and, at most, verifies its own rebuild against it.
Done: an optional unique_id field (the canonical loom_<routing-key>) is now carried on the per-entity push payloads:
DataPointValueChangedPayloadCustomDataPointStateChangedPayloadSysvarChangedPayload,ProgramExecutedPayload— hub keys; these use the post-connect serial suffix the daemon already has (it builds them only for post-connect entities, so this stays consistent — see Serial availability above). The program key resolves the program name from the hub model (the event carries only the id); it is omitted until the program is known.OptimisticRollbackPayload,DeviceTriggerPayload— ride the same per-data-point topics and route to the same entity, so they carry the same key as the value-change.
How it landed:
unique_id stringwas added to the payload structs (internal/north/rest/ws/), populated at the publish boundary viaroutingkey.CanonicalUniqueID(serialSuffix, address, parameter, eventPrefix)— the serial suffix comes from(*central.Registry).SerialSuffix;- the
unique_idproperty was added to each schema inassets/openapi.yaml; openccu-loom-typesis regenerated downstream from that spec;- the client consumes
payload.unique_idwhen present, and keeps the rebuild path as a verifiable fallback for payloads that omit it.
The field is optional so the contract stays backward-compatible: clients fall back to rebuild when it is absent.