Skip to content

[Core] No core support for removing child devices an integration no longer provides #82

Description

@zigpy-review-bot

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_device returns — but only while neither the entity's own async_removed_from_registry nor its async_will_remove_from_hass suspends. 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 returns disabled_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_registry so 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):

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, still disabled_by: user.
  • With an entity whose async_will_remove_from_hass awaits: the state was still in place when async_remove_device returned, 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_entities set on the config entry ("enable newly added entities" off): the entity came back under its own entity ID but disabled_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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    HA coreNeeds (or is caused by) a change in HA Core or the HA frontend

    Fields

    Priority

    None yet

    Size

    None yet

    Area

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions