Repository navigation
[Core] No core support for removing child devices an integration no longer provides #82
Description
Problem
Child devices (home-assistant/architecture#1414) are created from a ChildDeviceInfo — implicitly by the entity platform when an entity carrying one is added, or explicitly with the device registry's async_get_or_create_child / async_get_or_create_child_from_info — but nothing in core prunes a child device the integration no longer provides. When the split of a device changes (ZHA: a re-interview, a quirk or allowlist change; Z-Wave / Matter: an endpoint going away; Tuya: a channel no longer in the device's status range), the entity platform moves the re-added entities to their new device, and the old child device stays in the device registry: empty, under the main device. The registry's async_cleanup does not drop it: its orphan scan covers main devices only, and its child device loop removes a child just when the parent device is missing or the config entry is gone.
The user cannot clean it up either. Removing a device from the UI goes through the integration's async_remove_config_entry_device with the ChildDeviceEntry as device_entry, so an empty child device can only go when that hook exists and returns True for it. Matter's hook in dev returns False for anything that is not a DeviceEntry ("This integration does not create child devices"), which home-assistant/core#186033 has to replace with a child device branch; ten of the 62 integrations that implement the hook refuse a non-main entry that way today — correct while they have no child devices, a hard stop once they do. ZHA, Z-Wave, ESPHome, Tuya, Guntamatic and Husqvarna Automower implement no hook at all, so for them the question does not even arise.
So every integration with child devices that can change has to prune them itself, against mechanics that are easy to get wrong.
DeviceRegistry.async_remove_device on a child device removes every entity of the same config entry and subentry that is still on it (EntityRegistry.async_device_modified, remove action). Core softens that: each entry is kept as a deleted entry and restored when the same unique ID is added again (home-assistant/core#145278) — the entity ID, name, icon, labels, aliases, area, categories, device class, hidden state, options, the registry entry ID, created_at and a user or integration disabled_by — and a deleted entry is not purged while its config entry and subentry still exist, removing either starting a 30-day clock. So an integration that removes a stale child device while its entities are being re-added mostly gets away with it. Mostly, because the restore is not the same thing as a move:
- The entity ID only comes back while it is still free. The state is dropped in an eagerly started task that normally completes before
async_remove_devicereturns — but only while neither the entity's ownasync_removed_from_registrynor itsasync_will_remove_from_hasssuspends. One that does can still hold its state when the integration re-adds the entity, and the new entry then gets a freshly generated entity ID instead of its own. - A previously enabled entity can come back disabled. The restored entry carries
disabled_by: None, which then goes through the new-entity preference, so on a config entry with "enable newly added entities" turned off the entity returnsdisabled_by: integration. Moving the existing entry does not touch it. - The entity's own teardown runs, and no registry restore undoes what it did — MQTT, for one, clears the retained discovery topic in
async_removed_from_registryso the entity is not rediscovered after a restart, which is also a suspension point of the kind the first bullet is about.
An integration that wants its entities to come through unchanged — same entity ID, same enabled state, no teardown — therefore has to move them to the main device before the child device goes, disabled ones included, which er.async_entries_for_device skips unless include_disabled_entities=True is passed. Which of the two an integration ends up doing is not a choice core puts in front of it; it falls out of the order in which that integration happens to prune and add its entities.
Until home-assistant/core#186750 (merged 2026-10-20) there was one more step: a moved entity kept a stale disabled_by: device (#81), so each integration had to fix the disabler by hand after every move, or an entity of a child device the user had disabled stayed disabled on the enabled main device. _async_update_entity now re-derives disabled_by whenever an update sets device_id or config_entry_id and the entity's current disabler is a derived one (none, config_entry or device; a user or integration disabler is kept), which also covers the entity platform, since moving an existing entry in async_get_or_create goes through it. That step is gone; the pruning itself is still every integration's own problem.
Integration implementations
Six integrations carry their own child device removal, in three shapes (state of 2026-10-23):
- Sync the children of a main device to the set the integration expects, moving the entities it still provides to the main device first. ZHA (TheJulianJES/core
zigpy-bot/zha-child-devices,_async_reconcile_child_devices) moves every entity of a stale child, disabled ones included. Z-Wave (Add Z-Wave child devices home-assistant/core#185985) moves only the entries whose unique ID is being rediscovered, as a staging step for the pending re-add, and leaves the rest to be removed with the device; that lookup omitsinclude_disabled_entities, so a disabled entity is left to the restore instead of staged (home-assistant/core@a59e40de4d8). Matter (Add Matter child devices home-assistant/core#186033) moves the entities,include_disabled_entities=True, only when the endpoint still exists and has just stopped needing a child device; a child whose endpoint is gone is removed with its entities (home-assistant/core@4aa65f455c2). - Move the entity, then remove the device it left behind if nothing uses it any more, recursing into the parent: MQTT (Add MQTT child device support home-assistant/core#186015,
async_cleanup_device_registry). - Plain
async_remove_device, letting the entities go with the child device: ESPHome (Add support for ESPHome child devices home-assistant/core#186150) for a sub device that is no longer in the device's config, Husqvarna Automower (Use child device in Husqvarna Automower for work areas home-assistant/core#180563) for a deleted work area. Both want exactly that, the provider being gone with its entities.
Two integrations ship child devices in dev today with no pruning at all: Tuya (multi-channel valves since home-assistant/core#182835, indexed phase sensors since home-assistant/core#182161, multi-channel switches in home-assistant/core#188169) — its device registry cleanup iterates dr.async_entries_for_config_entry, which returns main devices only — and Guntamatic (heating circuits, home-assistant/core#179770), which has no device removal code.
Three shapes, six implementations, two integrations without one, and one undocumented set of rules — which entities a child device removal takes with it, what the restore does and does not bring back, which entity registry lookup silently skips the disabled ones, and which hook has to approve the removal a user asks for.
Evidence
On the ZHA branch at TheJulianJES/core@ce12ea0a44a (zigpy/zha#947 at zigpy/zha@a3bd6ff5), with _async_reconcile_child_devices patched out: a two-socket Tuya TS011F that loses its child devices in a re-interview keeps both empty "Channel 1" and "Channel 2" child devices under the main device, one of them still disabled by the user. With the reconcile in place, both are removed and the switches keep their entity IDs and names on the main device.
The restore behaviour and its two gaps were probed separately against core dev at home-assistant/core@e894522ba30, with a child device entity whose child device is removed without moving it and whose unique ID is then added again on the main device:
What the probe showed
- Baseline, both immediately after the removal and after it had settled: the entity came back on the main device with its original entity ID, its customized name, icon, labels and aliases, its registry entry ID and its
created_at. A second run with the entity disabled by the user: same entity ID, stilldisabled_by: user. - With an entity whose
async_will_remove_from_hassawaits: the state was still in place whenasync_remove_devicereturned, the entity ID was not available, and the re-added entity was registered under a newly generated entity ID (…_outlet_1_power→…_strip_power). - With
pref_disable_new_entitiesset on the config entry ("enable newly added entities" off): the entity came back under its own entity ID butdisabled_by: integration, having been enabled before the removal.
Proposal
A device registry helper for integrations with changing child devices, for example one that syncs the child devices of a main device to the set the integration expects: it removes the others after moving all of their entities (disabled ones included) to the main device. Alternatively, an option on async_remove_device for a child device to move its entities to the main device instead of removing them.
Either way the value is mostly in there being one documented answer, with the entity handling and the disabled entities taken care of, instead of each integration arriving at its own — or, as in Tuya and Guntamatic, at none.
Related
- [Core] Moving an entity to another device keeps a stale
disabled_by: device#81: moving an entity to another device kept a staledisabled_by: device— fixed by Fix derived entity disabled_by handling home-assistant/core#186750, so integrations no longer have to fix the disabler themselves after a move. - [Core/ZHA] Investigate child device support #73, Add child devices for multi-channel devices zha#947: ZHA child devices.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Size
Area
Activity