Skip to content

Device registry: scope every lookup to its entry, and resolve panel ids from before 2026.8 - #270

Merged
cayossarian merged 6 commits into
mainfrom
fix/scoped-device-identifier-lookups
Sep 11, 2026
Merged

Device registry: scope every lookup to its entry, and resolve panel ids from before 2026.8#270
cayossarian merged 6 commits into
mainfrom
fix/scoped-device-identifier-lookups

Conversation

@cayossarian

@cayossarian cayossarian commented Sep 11, 2026

Copy link
Copy Markdown
Member

What

  • Home Assistant's deprecation warning for device_registry.async_get_device is resolved. Every device lookup by identifier is now scoped to the config entry it acts for, through async_get_device_by_identifier; the unscoped call stops working in Home Assistant 2027.8.
  • The WebSocket commands resolve a panel through one function, websocket_panel.resolve_panel_device, instead of two copies that had drifted — topology took an arbitrary one of a device's entries where the adopted commands checked its domain. Neither reads the deprecated DeviceEntry.config_entries any more.
  • A panel device id saved before Home Assistant 2026.8 resolves to the panel's current device. 2026.8 split every device shared between config entries into one device per entry, each with a new id, and a panel was shared whenever a helper had linked itself to it. For such an id, topology returned no sub-devices, or — depending on the hash seed — refused a loaded panel as not_loaded.
  • Topology names the panel's current device and its entry, panel_device_id and config_entry_id. The card uses them (Find the panel by the device topology names span-card#16), so a card holding an old id works with nothing to change. The bundle is rebuilt from span-card main at 1c65fd3, which also brings the empty sub-device skip (Skip a sub-device with nothing to draw span-card#15).
  • Version 2.1.2. 2.1.2b1 was published and tested: no device-registry deprecation warnings.

How

  • config_entry_id is threaded through the extension partition, the adopted identity resolution, the curation rows, the seven entity builders and the five platform setups.
  • Every threaded site is pinned by a test that registers the cards first. Replacing the scope with a bogus entry at any of the eight sites fails the tests aimed at it; before, eight of thirteen sites could be broken with the suite still passing.
  • The pre-2026.8 id tests reach the registry through Home Assistant's own migration of a pre-2026.8 store, with either entry as the old device's primary.
  • resolve_panel_device refuses a child-device id as not found, and a comment marks where Home Assistant 2026.9's async_get_device_and_config_entry_for_domain replaces its lookup once 2026.9 is the minimum.

CI

main moved to Home Assistant 2026.9.0 (#267, merged with lint-and-test failing on eight mypy errors, all because 2026.9 lets async_get answer with a child device, which has no via_device_id). This PR fixes all eight. Four were in the WebSocket handlers it rewrites; in services.py (the favorite service) and additions.py (the new-entity announcement), a child device is now treated as the absence of a SPAN device, since SPAN registers none — the same isinstance narrowing resolve_panel_device uses, which holds on 2026.8 and 2026.9 alike. A test that read the device registry as a mapping, which 2026.9 deprecates, now lists the entry's devices instead.

The branch merges main and relocks uv.lock, which #267 left at 2026.8.3. CI is green on 2026.9.0, and the suite also passes on 2026.8.3, the minimum supported version.

Home Assistant 2026.8 gave every device a single owning config entry.
It reports `async_get_device(identifiers=...)` as deprecated and says it
stops working in 2027.8: it searches every config entry, but an
identifier is unique only within one. The panel's own registration had
already moved to `async_get_device_by_identifier`; four lookups had
not -- the extension card check, the adopted identity freeze, and both
card lookups the curation editor makes.

The ambiguity is latent rather than live. A second SPAN entry cannot
hold this panel's identifiers today, because the config flow keys
entries on the serial every identifier embeds. Were it reached, an
adopted device would be frozen onto the other entry's identifier --
permanently, since the freeze is by design irreversible -- the editor
would group rows under that entry's device, and an extension row would
be admitted around the deferral and mint a nameless card of its own.
That guard is a property of the flow rather than of the registry, and
the lookups no longer rest on it.

Each lookup now takes the entry it is acting for. The scope threads
through the extension partition, the adopted identity resolution, the
curation rows, the seven entity builders and the five platform setups,
which state it beside the panel link they already pass.

A wrong scope fails silently -- an adopted entity re-minted under a new
unique_id, which the registry reads as a replacement that takes its
history, or an extension entity that never appears -- so every site is
pinned by a test that registers the cards first: the freeze through the
builders, and each platform's setup in test_platform_entry_scope.py.
Replacing the scope with a bogus entry at any of the eight sites is
caught by the tests aimed at it. The identity-freeze tests that stubbed
the registry with a lambda, blind to the scope, now run against the real
registry, and three tests cover the other-entry case directly.

The two WebSocket commands that take a panel device id found its entry
by looping over `DeviceEntry.config_entries`, which 2026.8 reduced to a
deprecated shim holding the device's one owner. They now share
`resolve_panel_device`, which checks the owning entry's domain. The two
copies had drifted: topology took an arbitrary member of the set without
checking its domain, so it refused a SPAN identifier on another entry's
device as not loaded where the adopted commands answered not a panel.

A device id saved before 2026.8 is resolved to the device it became.
That release split every device with several config entries into one
device per entry, each with a new id, and a SPAN panel was one whenever
a helper built on its sensors had linked itself to the panel's device;
the dashboard card keeps the id it was configured with. The registry
answers such an id with a read-only composite until 2027.8, but its
`config_entry_id` is the old device's primary and no sub-device points
at it any more, so topology returned no sub-devices -- or, depending on
the hash seed, refused a loaded panel as not loaded. The resolver maps
the old id to the split a SPAN entry owns through
`async_get_devices_for_composite_device_id`, and topology matches
sub-devices against that device while still echoing the id it was sent.
A child device, which `async_get` can answer with from 2026.9, is
refused as not found, as the helper 2026.9 adds for this lookup refuses
it; a comment marks where that helper replaces the lookup once 2026.9 is
the minimum.
The tests reach that registry through Home Assistant's own migration of
a pre-2026.8 store, with either entry as the old device's primary.

Topology also names the panel's current device and the entry that owns
it, `panel_device_id` and `config_entry_id`. The card looked the id it
holds up in the device list, which has no entry for an old id, so it lost
the panel's config entry and the area its circuits fall back to. The
card, rebuilt from span-card 849aefc, now takes both from topology, and
it and the dashboard stop reading the device list's deprecated
`config_entries`.

The tests that still looked devices up with the unscoped call now ask
within their entry.

websocket-api.md records that an old id is still accepted, developer.md
names the shared resolver, and the 2.1.2 changelog describes both fixes
as a user meets them.
Rebuilt from span-card main at 1c65fd3, which merges the empty
sub-device skip (#15) beside the topology identity fix (#16) this bundle
already carried. A sub-device with nothing to draw -- the Microgrid
Interconnect, whose one entity is a diagnostic -- is left off the card
rather than drawn as a bare header and settings icon.
Home Assistant 2026.9 lets `DeviceRegistry.async_get` answer with a
child device, a part of another device that has no `via_device_id`. Two
places read that field off whatever `async_get` returned: the favorite
service, walking an entity's device up to its panel, and the
new-entity announcement, naming the sub-device an entity sits on. mypy
against 2026.9 flags both, and at runtime a child device answers the
missing field with a deprecation warning.

Neither can concern SPAN: the integration registers no child devices,
and a `via_device_id` can never name one. So a child device is treated
as the absence of a SPAN device, with the `isinstance` narrowing
`resolve_panel_device` uses, which holds on 2026.8 and 2026.9 alike.

The favorites service tests faked devices with bare mocks and replaced
the whole `dr` module, so no fake was a `DeviceEntry`; they are now
spec'd to it, with the real class kept on the patched module, and a new
test refuses an entity on a child device -- it fails if the check goes
back to testing for None. The adoption test that listed devices through
`registry.devices` as a mapping, which 2026.9 deprecates and raises on
in tests, lists this entry's devices through
`async_entries_for_config_entry`.
Brings in the ha-core bump (#267), which moved the pins to Home
Assistant 2026.9.0 without relocking. uv.lock is relocked against them:
Home Assistant, its stubs and the test plugin, and the dependency
changes 2026.9.0 brings with it; no span library moves.
@cayossarian
cayossarian merged commit 4080933 into main Sep 11, 2026
7 checks passed
@cayossarian
cayossarian deleted the fix/scoped-device-identifier-lookups branch September 11, 2026 07:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant