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.
A re-key costs differently on each plane
ADR 0068 settles what a unique_id promises, and the two north-bound planes differ. On REST/WS a key may change provided the change ships with the consumer-side migration and the old key is derivable from data the consumer already receives. On MQTT discovery there is no migration to ship — Home Assistant's MQTT integration has no unique_id migration path — so a change there is a break, permitted but never silent: it carries the six obligations in Breaking a Published Identity, the orphan sweep among them.
Either way, this page is where the transition is recorded, before the change is released.
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*), virtual-remote channels (BidCoS-*,HmIP-RCV-1,VCUvirtual-remote range), and CUxD addresses (CUX*).
CUxD is scoped here and not in the legacy reference
A CUxD serial is CUX + a two-digit device type + a five-digit running number chosen per CCU, conventionally starting at 1 — the first (28) System device is CUX2801001 on essentially every install. Two CCUs therefore declare byte-identical keys for their CUxD data points. The daemon adds the serial suffix for this class, and so does the reference from aiohomematic 2026.8.7 onward (aiohomematic#3370). Against an older reference a CUxD key is the one case where the migration below cannot simply prepend loom_; against 2026.8.7 or newer it is an ordinary case. Either way the entities re-key once on the upgrade that adopts the rule. Channel-level keys stay unscoped on both sides.
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 (vid 12345) | a1b2c3d4e5_sysvar_aussen-temperatur | loom_11a0001234_sysvar_12345 |
Program My Prog (id 1234) | a1b2c3d4e5_program_my-prog | loom_11a0001234_program_1234 |
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 |
CUxD CUX2801001:1 STATE | cux2801001_1_state | loom_11a0001234_cux2801001_1_state |
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:
import re
# A CUxD address anywhere in the key: at the start for a plain data point,
# behind a family prefix (`event_<central>_…`, `calculated_…`) otherwise.
# Matching the address rather than enumerating prefixes keeps the rule
# stable when a new family prefix appears.
_CUXD_ADDRESS = re.compile(r"(?:^|_)cux\d+_")
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):]}"
# CUxD addresses repeat across CCUs but carried NO central prefix in the
# legacy key, so the serial suffix is inserted rather than swapped.
if _CUXD_ADDRESS.search(old):
return f"loom_{serial_suffix}_{old}"
# 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, HA's registry refuses the second rename because the target
unique_idalready exists; the entity keeps its old key. Log these. -
System variables and programs are keyed on the CCU's id. A sysvar's
unique_idisloom_<serial10>_sysvar_<vid>and a program's isloom_<serial10>_program_<id>, not the slug of either name. The name was never an identity, in two ways. It is editable — a rename in the WebUI moved the key and took the entity's history, area and automations with it. And it is not unique:hub_slugcollapses punctuation and case, soAlarm: KücheandAlarm Kücheboth slug toalarm-kucheand produced byte-identical keys — HA registered whichever discovery config arrived first and dropped the other variable's entity outright, permanently, because the payload is retained on the broker.Both implementations apply this rule as of daemon 0.68.0 and reference 2026.8.8. Two consequences for a client:
- Every sysvar and program entity re-keys once on the upgrade to this release. Rewrite
loom_<serial10>_sysvar_<slug>toloom_<serial10>_sysvar_<ise_id>in a second registry pass; the discovery payload carries the new key, and the state topic is unchanged, so a client that consumesunique_idfrom the payload needs no mapping table of its own. - A rename in the CCU WebUI no longer re-keys the entity. Under the old rule it did, orphaning the entity's history and every automation built on it. That is the reason the change is worth one re-key now.
A sysvar whose id the daemon has not resolved yet still falls back to the slug, so a client must accept both shapes during bring-up. - CUxD needs the rule in the rebuild path too. Rewriting the registry is only half of it: a client that rebuilds the key from
address+parametermust also put the serial suffix in front forCUX*addresses, or it rebuilds a key the daemon never emits and every CUxD value update routes to nothing. The daemon's optionalunique_idfield on the push payloads (below) already carries the scoped key, so a client that consumes it inherits the rule for free and can verify its own rebuild against it. - 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 usesentry.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 theunique_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). - Every sysvar and program entity re-keys once on the upgrade to this release. Rewrite
Transitions on record¶
Each applied re-key is recorded on Unique-ID Transitions on Record, written before the release that carries it.
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; - the downstream client's wire bindings are regenerated 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.