Unique-ID Transitions on Record¶
The log of every applied re-key of a published identity: one entry per change, written before the release that carries it, per ADR 0068 and Breaking a Published Identity. The current schema and mapping rules are on HA Unique-ID Migration; this page is the history a client reads when it has to migrate stored identities.
The discovery slug is unified onto the shared rule — daemon unreleased¶
This one moves a node_id, an object_id and a device identifiers value. It does not move a unique_id or a default_entity_id, and it moves no state, command or availability topic. That distinction is the whole of its cost, so it is stated first: no entity loses its history, its long-term statistics or its entity_id.
What changed. The daemon carried two slug rules for the same job. The shared one (go-hamqtt's topic.Slug, used by every other consumer of that module) and a second copy inside the daemon that disagreed with it on eight inputs in two classes. Both disagreements were defects in the daemon's copy:
- non-German accented Latin was dropped rather than transliterated. A CCU or a system variable named
Caféslugged tocaf— the same identifier as one namedCaf. Two objects, one retained discovery config, and Home Assistant kept whichever arrived last. The operator saw one entity where they had configured two, with no warning anywhere. - a literal
__passed through. Only generated underscore runs collapsed.Watchdog:_CCU-Jack— a real CCU name, cited as the motivating example in the daemon's own source for as long as the rule existed — slugged towatchdog__ccu-jack, a spelling nothing else in the daemon could produce.
Old and new, side by side. The affected inputs, and only these:
| Name | Old slug | New slug |
|---|---|---|
Café | caf | cafe |
Señor | se_or | senor |
Garçon | gar_on | garcon |
Ångström | ngstroem | angstroem |
Ærø | r | aeroe |
Søren | s_ren | soeren |
a__b | a__b | a_b |
Watchdog:_CCU-Jack | watchdog__ccu-jack | watchdog_ccu-jack |
German names are unaffected — CCU Küche was ccu_kueche before and after, as were Heizung Büro, Außen Temperatur and s0_Sensoren_Hülle_EG. So is any name already inside [a-z0-9_-].
A real example, for a CCU named Café:
old homeassistant/switch/caf_0001d3c99c1234/1_state/config
new homeassistant/switch/cafe_0001d3c99c1234/1_state/config
old device.identifiers = ["openccu-loom_central_caf"]
new device.identifiers = ["openccu-loom_central_cafe"]
unchanged unique_id = loom_11a0001234_0001d3c99c1234_1_state
unchanged default_entity_id = (unchanged, or absent on this plane)
unchanged state_topic = gh/status/Café/HmIP-RF/0001D3C99C1234/1/values/STATE
What is lost, named. Less than a unique_id re-key costs, and it is not nothing:
- Nothing at the entity level. Home Assistant keys its entity registry on
unique_id, which does not move here. History, long-term statistics, theentity_id, custom name, icon, category and every automation or dashboard card naming the entity all survive. Measured, not assumed: the entity keeps its registry row across a discovery-topic move — see ADR 0070's second amendment, taken on a live 2026.9 instance. - At the device level, the device row is replaced.
identifiersis the device registry's key and Home Assistant has no migration for it either, so the CCU's device card and the cards of its central-scoped devices (BidCoS-RF,BidCoS-Wir,HmIP-RCV-1, theINT000*internals and the CUxD roots) are re-created under the new identifier. A device-level area assignment, a renamed device and a device-level disable are lost, and any entity that inherited its area from the device inherits from the new, unassigned one. An entity with its own explicit area keeps it. - Automations that target
device_idrather thanentity_idbreak. The device id changes with the device row. Home Assistant's own guidance is to target entities, and this is one of the reasons; an automation written in the UI may well carry a device target without the author having chosen one. - The two collided entities un-collide. On an installation that hit the
Café/Cafdefect, the object that was silently losing the race reappears. That is the fix, and it reads as a new entity.
Retracting the pre-scope configs is opt-in, and here is why. ADR 0068 obligation 3 says a break must retract what it previously published, so the old configs disappear instead of lingering as permanently unavailable twins. On this plane that obligation cannot be discharged automatically, because the pre-scope spelling and a live sibling's spelling are the same string. The sweep is handed a publisher.ConfigTopic, which carries the node id and nothing else — no payload, so no state_topic naming the owning daemon's base. ccu-haus_000a0000000001, alarm, security and daemon are what this daemon wrote before the upgrade and what a sibling on the default base is publishing right now, and nothing in the topic tells them apart.
Claiming that namespace by default would therefore have every custom-base daemon retract all of a default-base sibling's device, hub, alarm and security configs on every boot. Home Assistant deletes those entities, and a device-registry row goes with its last entity; identifiers has no migration path, so that is not recoverable. For alarm, security and daemon the node ids are fixed literals, so this needs no name coincidence at all — any two daemons have it. Doing it once behind a persisted marker was considered and rejected: once is already unrecoverable.
So it is an operator decision, because the operator is the only party who knows whether a default-base sibling exists:
With it off (the default) the daemon only ever claims its own <base-slug>_… namespace — nothing of anyone else's is touched, and the pre-scope configs stay retained on the broker until you clear them. With it on, discoveryNodePrefixes and daemonLevelNodeID (internal/north/mqtt/retain_cleanup.go) also accept the unscoped spellings and the first sweep after start retracts them. Both are retraction-only — nothing publishes through them — and both are deletable after one release, exactly like legacyDiscoverySlug above.
Which way to go
One daemon on this broker, or every daemon on its own non-default base → set discovery_retract_unscoped: true, start once, let the sweep run, set it back to false. Nothing else on the broker owns the unscoped node ids, so the retraction is exactly your own history.
A sibling daemon on the default topic_base shares this broker → leave it false. Either give that daemon a non-default base of its own first (then both can opt in), or clear the stale configs by hand:
# list what is left under the pre-scope node ids, then retract one:
mosquitto_sub -h <broker> -t 'homeassistant/#' -v -W 3 \
| awk '$1 ~ /\/config$/ {print $1}' | sort -u
mosquitto_pub -h <broker> -r -n -t '<the/config/topic>'
An empty retained publish (-r -n) is exactly what the sweep does.
How to see the blast radius before upgrading. You are affected if, and only if, one of the following carries a non-German accented Latin character (é è ê ë á à â å æ ø ñ ç í ì î ó ò ô ú ù û) or a literal __ / :_ pair:
- your CCU's configured name — this is the expensive one, because it moves every device card of that CCU. Check
centralin the add-on configuration, or read it off a topic:mosquitto_sub -h <broker> -t '<base>/status/+/online' -v -W 3. - a system variable or program name, which moves only that entity's discovery topic (its
unique_idis keyed on the ISE id).
The second class is also visible on the broker without knowing any names: a discovery topic carrying a double underscore is one that will move.
mosquitto_sub -h <broker> -t 'homeassistant/#' -v -W 3 \
| awk '$1 ~ /\/config$/ && $1 ~ /__/ {print $1}' \
| sort -u
The first class cannot be found that way — a dropped accent leaves no trace in the result — so for it, read the names: your CCU's, and any system variable or program whose name you know carries one.
The simpler check is the first one: if your CCU name and every sysvar and program name is plain ASCII or German, nothing moves and the upgrade is a no-op on this plane. That is the overwhelmingly common case — on a typical German-language fleet zero entities are affected. On an installation with one affected CCU name, every entity of that CCU moves discovery topic (hundreds to low thousands) while every one of them keeps its registry row; the device rows of that CCU plus its handful of central-scoped devices (typically three to six) are re-created.
Operator steps.
Before upgrading — only if the check above says you are affected:
- Note the area of each affected device (the CCU card and any
BidCoS-RF/HmIP-RCV-1/INT000*/ CUxD card), and any device you renamed. These are what you will re-apply. - Note any automation that targets one of those devices by device, rather than by entity.
- Optionally, rename the CCU to a plain-ASCII name before upgrading and let the pre-unification daemon's sweep handle that move under the old rule. This trades one move for another and is not recommended; it is listed because it is the only way to control when the move happens.
After upgrading:
- Start the daemon once and let the first orphan sweep complete. The old device cards empty out and Home Assistant removes them.
- Re-apply the device areas and names you noted.
- Re-point the device-targeted automations from step 2 at entities. Home Assistant's Repairs panel and the automation editor flag an unresolvable device target.
- Nothing else. Entity ids, history and statistics need no action.
Announced in the root CHANGELOG.md and both add-on changelogs under packaging/ha-addon/.
The discovery node id is scoped by the topic base — daemon unreleased¶
This one moves a node_id on every plane, and on the three daemon-level planes (alarm, Security & Safety, add-on self-update) it also moves the unique_id and the default_entity_id. It moves no device identifiers, no via_device, and no state, command or availability topic.
It affects a narrow population: only installations that set north.mqtt.topic_base to something other than openccu-loom. If you never touched that key, nothing on this plane moves — not one byte — and you can stop reading here.
If you do have a custom topic_base, read the two boxes below
The identity move on the three daemon-level planes is not free (Why the daemon-level unique_ids move too), and the retraction of the pre-scope configs is opt-in because doing it automatically would delete a sibling daemon's entities (Retracting the pre-scope configs).
What changed. homeassistant/ is a single tree on a single broker, shared by every integration and every daemon publishing into it. Everything else the daemon writes is namespaced by topic_base — that is the entire purpose of the knob, and ADR 0006 has promised since the initial release that "non-default Bases give multi-daemon installations distinct namespaces". The discovery plane never read it. The node id was <central-slug>_<address> for device entities, <central-slug>_<kind> for hub entities, and the bare literals alarm, security and daemon for the three daemon-level planes, and none of the three consults the base. Two daemons that had been given different topic bases precisely so they would not collide wrote byte-identical homeassistant/.../config topics anyway.
The node id now carries the base, when the base is not the default:
Old and new, side by side. A daemon configured with topic_base: house, bridging a CCU named ccu-haus:
old homeassistant/binary_sensor/ccu-haus_000a0000000001/4_state/config
new homeassistant/binary_sensor/house_ccu-haus_000a0000000001/4_state/config
old homeassistant/sensor/ccu-haus_sysvars/4711/config
new homeassistant/sensor/house_ccu-haus_sysvars/4711/config
old homeassistant/alarm_control_panel/alarm/master/config
new homeassistant/alarm_control_panel/house_alarm/master/config
old homeassistant/update/daemon/addon_update/config
new homeassistant/update/house_daemon/addon_update/config
unchanged unique_id = loom_000a0000000001_4_state (per-device plane)
unchanged device.identifiers = ["openccu-loom_000a0000000001"]
unchanged state_topic = house/status/ccu-haus/HmIP-RF/000A0000000001/4/values/STATE
The three daemon-level planes move their identity as well, because moving only their topic made them worse rather than better — see the next box:
old unique_id = openccu-loom_alarm_master
new unique_id = house_openccu-loom_alarm_master
old unique_id = loom_security_state
new unique_id = house_loom_security_state
old unique_id = loom_addon_update
new unique_id = house_loom_addon_update
default_entity_id follows each of the three (it is seeded from them)
unchanged device.identifiers = ["openccu-loom_daemon" | "openccu-loom_security"]
On the default base every one of those lines is unchanged, old and new.
Why the deletions were the real damage. The overwrite race is the visible half: the retained config the broker keeps is whichever daemon published last, so one daemon's entities silently point at the other's state topics. The half that actually removed entities is the orphan sweep. RunDiscoveryOrphanCleanupOnce decides what to retract from the node id alone — the sweep sees publisher.ConfigTopic, which carries no payload, so the state_topic that would name the owning daemon is not available to it. Two daemons sharing a node-id namespace therefore each ran a pass that judged the other's live configs against its own claim set and retracted every one it did not itself publish, on every boot, with nothing in any log. The scope is what ends that.
What this does NOT fix, said plainly. Two daemons against the same CCU still declare byte-identical unique_ids and device identifiers: both are keyed on the device address and the CCU serial, never on the topic base or the central name. Home Assistant keys its entity registry on the first and its device registry on the second, so one HA instance still resolves both daemons to one set of entities no matter how the discovery topics are spelled — the second daemon's configs are dropped as duplicate unique ids rather than merged. Two daemons against one CCU feeding two HA instances (or two brokers) is the case this change makes work, and it is the case the base was always for.
And one ambiguity that is documented rather than fixed. The scope is joined to the node id with _, and the node id's own first segment — the central slug — is joined to the rest with _ too, so the two boundaries are indistinguishable. topic_base: haus with a CCU named CCU, and the default base with a CCU named Haus CCU, both render haus_ccu_<address>. Two such daemons on one broker collide exactly as they did before the scope existed.
It is not fixed on purpose. Disambiguating it means changing the separator (or escaping _ inside the base slug), which would move the node id of every non-default-base installation a second time, one release after this one — a second migration for a collision that additionally requires the operator to have named their topic base and their CCU after the same thing. The mitigation is a naming rule instead: pick a topic_base that is not a prefix of any CCU name on the broker. If a node-id move is ever required for another reason, the separator will be revisited in that same release rather than on its own.
What is lost, named. On the per-device and hub planes, nothing: Home Assistant keys the entity registry on unique_id, which does not move there, and a discovery-topic move alone keeps the registry row — measured on a live 2026.9 instance, ADR 0070's second amendment. identifiers does not move anywhere, so no device row is re-created and device areas, device renames and device-targeted automations all survive untouched.
Why the daemon-level unique_ids move too, and what that costs
Moving only the node id of the alarm, Security & Safety and add-on-update planes made two daemons less able to coexist, not more. Those three planes carry no <central> segment (ADR 0052) and their ids are fixed literals — loom_addon_update, openccu-loom_alarm_<zone>, loom_security_<key> — with nothing in them that differs between two daemons. Two daemons therefore wrote two distinct config topics declaring the same unique_id, and Home Assistant's MQTT integration rejects the second ("Platform mqtt does not generate unique IDs"). The semantics went from "last writer wins, and a restart repoints the entity at the live daemon" to "first writer wins permanently": the second daemon's alarm panels, security entities and add-on updater never appeared at all, and its retained configs were re-rejected on every HA restart.
So those three unique_ids carry the scope as well — and only on a non-default base, for the same reason the node id does. The cost, for an installation that has a custom topic_base and only one daemon: the alarm panels, the Security & Safety entities and the "Add-on Update" entity are re-keyed. Each loses its history, its long-term statistics, its entity_id, any rename, its area assignment and every automation that names it. The old registry rows remain as unavailable "restored" entities until you delete them (Settings → Devices & services → Entities, filter Restored).
Per-device and hub entities are untouched by this: they are keyed on the CCU serial or the ISE id, which already differ between two daemons.
The orphan swept, not left behind. The sweep retracts the pre-scope configs on the first start after the upgrade, so the old entities disappear rather than lingering as permanently unavailable twins. It was extended for this: discoveryNodePrefixes (internal/north/mqtt/retain_cleanup.go) keeps the unscoped spelling of every central prefix alongside the scoped one, and daemonLevelNodeID accepts alarm as well as <base-slug>_alarm. Both are retraction-only — nothing publishes through them — and both are deletable after one release, exactly like legacyDiscoverySlug above.
Upgrade a default-base sibling first
Those retraction entries have a cost while they exist, and it is the only new hazard this change introduces. A daemon on a non-default base treats an unscoped retained config as its own, in order to retract it — and a sibling daemon still running on the default base is publishing under exactly those unscoped node ids. For one release, upgrading the custom-base daemon while a default-base sibling is live will have the first sweep the second's entities.
This is not a regression — before the scope existed the two shared the namespace unconditionally and swept each other on every boot — but it is avoidable. If you run two daemons against one broker and only one has a custom base, upgrade them together, or give the second daemon a non-default base of its own before upgrading either. Ownership cannot be decided any more precisely: the sweep sees the node id and nothing else.
How to see the blast radius before upgrading. One question decides it:
No topic_base key, an empty one, or openccu-loom → nothing moves, the upgrade is a no-op on this plane, and no sweep runs. Anything else → every discovery config this daemon publishes moves to the scoped node id, and every one of its entities keeps its registry row.
To count them, and to see the old node ids that will be retracted:
mosquitto_sub -h <broker> -t 'homeassistant/#' -v -W 3 \
| awk '$1 ~ /\/config$/ {split($1,a,"/"); print a[3]}' \
| sort -u
Every node id in that list that does not already start with your topic_base slug plus _ is one that moves.
Operator steps.
Before upgrading:
- Check
topic_baseas above. If it is the default, you are done. - Note whether any other daemon on this broker runs on the default base. That single fact decides
discovery_retract_unscopedbelow. - If you have automations that arm an alarm panel or read a Security & Safety entity, note which ones — those entities are re-keyed and the automations will need to be re-pointed at the new entity ids.
After upgrading:
- Start the daemon. Per-device and hub entities keep their registry rows, their history and their
entity_id; nothing to do for them. - The alarm, Security & Safety and add-on-update entities appear as new entities. Re-point the automations from step 3 and delete the old rows (Settings → Devices & services → Entities, filter Restored).
- Clear the pre-scope discovery configs: set
discovery_retract_unscoped: truefor one start if no default-base sibling shares the broker, otherwise clear them by hand — see the tip above.
Announced in the root CHANGELOG.md and both add-on changelogs under packaging/ha-addon/.
Channel event entities move onto the event-group layout — daemon 0.69.0¶
Old — the generic channel-id helper with an event leaf, no family marker, and the central scope in front of the whole key:
New — the routing key the model publishes for the same event group:
What is lost. Home Assistant treats the re-keyed entity as a new one. The old entity's history, its area assignment and every customisation (name, icon, category) do not carry over, and any automation, script or dashboard card that names the old entity id stops resolving. Its entity id changes with the object id — event.<device>_<channel>_event becomes event.<device>_<channel>_event_group_keypress.
Why the object id moves too, and not just the id. Home Assistant reads unique_id only in its entity constructor; a discovery update never re-reads it. Publishing a new id onto the same topic therefore changes nothing on a running instance and would surface unannounced at its next restart. Moving the object id publishes the entity on a new topic and leaves the old config behind, which makes it a genuine orphan.
The mitigation, and its limit. RunDiscoveryOrphanCleanupOnce retracts the old config on the first start after the upgrade, so the old entity disappears instead of lingering as permanently unavailable beside the new one. The sweep does not preserve history — nothing can, across a unique_id change. It converts a silent zombie into a clean disappearance the operator sees once and acts on once.
Blast radius before upgrading. Every keypress channel is affected. The affected entities are exactly the retained discovery configs whose object id still ends in _event, so read them off the broker:
mosquitto_sub -h <broker> -t 'homeassistant/event/+/+/config' -v -W 3 \
| awk '$1 ~ /_event\/config$/ {print $1}'
Or, from the Home Assistant side, search the entity list for _event within this integration.
A channel's event groups are also served per channel at GET /devices/{addr}/channels/{no}/event-groups; there is no collection endpoint that lists them across devices, so the broker view above is the quicker inventory.
Operator steps. None are required — the sweep runs on first start. Only automations and dashboard cards that name the old entity id need repointing; Home Assistant's own "Repairs" and the automation editor flag an unresolvable entity id.
Impulse and device-error entities are new, not re-keyed: they were added in the same unreleased cycle and have never been published under another identity.
Sysvar WebSocket frames carry the model identity — daemon 0.69.0¶
Old — the sysvar.changed frame keyed on the variable's name slug, unconditionally:
New — the frame carries what the model publishes, which keys on the CCU's vid once a hub scan has resolved one:
Scope — narrower than it looks. Only sysvars with a resolved vid change. A variable the model has not scanned yet still gets the name-keyed fallback, which is the same value as before, so nothing changes mid-scan. REST already published the vid-keyed id: this frame was the one surface disagreeing with the rest of the daemon, which is the defect being fixed rather than a new scheme being introduced.
What is lost. A client that seeded its entity registry from these frames alone — rather than from REST — holds entities under the name-keyed id. Those entities keep their history only if the client re-keys them; otherwise a new entity appears beside the old one and the old one goes stale. The affected client in this family, homematicip_local, already migrates hub keys from the name slug (_async_migrate_hub_keys_from_name_slug), so it re-keys on the next start.
Blast radius before upgrading. Compare the two ids for every sysvar:
curl -s -u user:pass http://<host>:8080/api/v1/sysvars \
| jq -r '.items[] | "\(.name)\t\(.unique_id)"'
Every row whose unique_id ends in a number rather than a name slug is a variable whose WebSocket frames change. A row ending in a slug is unaffected.
No MQTT sweep applies. This is the REST/WebSocket plane, which has no retained discovery payload to retract; nothing is left behind to orphan. The MQTT plane is untouched by this change.
Event groups move onto the reference layout — daemon api 7.25.0¶
Old loom_<channel>_event_group/<kind> — the channel first, a slash, and the kind unshortened:
New loom_event_group_<kind>_<channel-unique-id>, which is what the reference stack and the Python client both build:
loom_event_group_keypress_vcu1234567_1
loom_event_group_keypress_11a0001234_bidcos_rf_1 ← virtual remote
Note where the central-id slot sits: inside the channel id, for the address families that need one, not in front of the whole key.
Who has to act: nobody. This is the unusual case where a key changes and no consumer is affected, and it is worth being explicit about why rather than leaving it to look like an oversight.
- The Python client never read this value. It recomputes the reference spelling itself (
ChannelEventGroup.unique_id), so its entities were already keyed the new way.EventGroupSummarydoes not appear in its source outside the generated wire models, and it does not callGET …/event-groups. - The MQTT discovery plane is untouched. It keys channel events on
loom_[<serial>_]<channel>_eventthrough a different builder, and that string does not change here. Its granularity — one event entity per channel rather than one per kind — is a separate question and stays as it is.
So there is no registry pass to run and no entity re-keys. What changed is that EventGroupSummary.unique_id is now usable: its own description always claimed a client could seed its event-entity registry from it, and until this release doing so would have bound entities to a key nothing else uses.
If you did key on the old value, the mapping is mechanical: loom_<channel>_event_group/homematic.<kind> → loom_event_group_<kind>_<channel>. TestEventGroupKeyFollowsTheReferenceLayout in tests/contract/ carries the exact expected strings for all four address families.