Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

All notable changes to this project will be documented in this file.

## [2.1.2] - 9/2026

### Fixed

- **Home Assistant's warning about a deprecated device lookup is resolved** — the integration, its card and its dashboard now find devices the way Home
Assistant 2026.8 asks, before the old ways stop working in 2027.8.
- **A SPAN Panel card set up before Home Assistant 2026.8 works again, with nothing to change** — Home Assistant gave the panel a new device ID during that
upgrade if a helper, such as a utility meter, was attached to it. A card still holding the old ID now finds the panel again: its battery, charger and other
devices, its monitoring and its circuits' areas all come back, where it showed no devices or an intermittent "not loaded" error.
- **The card no longer shows an empty tile for the Microgrid Interconnect** — a device with nothing to display is now left off the card rather than drawn as a
bare header and settings icon.

## [2.1.1] - 8/2026

### Added
Expand Down
4 changes: 3 additions & 1 deletion custom_components/span_panel/additions.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,9 @@ def _sub_device_name(devices: dr.DeviceRegistry, registry_entry: er.RegistryEntr
if registry_entry.device_id is None:
return None
device = devices.async_get(registry_entry.device_id)
if device is None or device.via_device_id is None:
# A child device (Home Assistant 2026.9+) hangs off nothing by
# `via_device_id`, and SPAN registers none, so it names no sub-device.
if not isinstance(device, dr.DeviceEntry) or device.via_device_id is None:
return None
return device.name_by_user or device.name

Expand Down
46 changes: 40 additions & 6 deletions custom_components/span_panel/adoption.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,13 @@ def adopted_identifier(panel_serial: str, anchor: str) -> str:
return f"{panel_serial}_{ADOPTED_IDENTIFIER_TOKEN}_{anchor}"


def resolve_identifier(registry: DeviceRegistry, panel_serial: str, device: AdoptedDevice) -> str:
def resolve_identifier(
registry: DeviceRegistry,
panel_serial: str,
device: AdoptedDevice,
*,
config_entry_id: str,
) -> str:
"""Return the identifier this install already uses for this device, or a new one.

**An adopted device freezes its identity anchor at first sighting.** Both
Expand All @@ -267,12 +273,22 @@ def resolve_identifier(registry: DeviceRegistry, panel_serial: str, device: Adop

The registry is the memory, so this needs no new persistence: a device that
exists was adopted before, and one that does not is being adopted now.

That memory is read within `config_entry_id`, never across every entry.
Identifiers are unique inside a config entry and nowhere else, so the unscoped
question can be answered by a device another entry owns -- and this panel's
device would then be frozen onto a stranger's identifier, permanently, since
the freeze is by design irreversible. Home Assistant deprecated the unscoped
lookup for that ambiguity and stops answering it in 2027.8.
"""
for candidate in (device.device_id, device.serial_number):
if candidate is None:
continue
identifier = adopted_identifier(panel_serial, candidate)
if registry.async_get_device(identifiers={(DOMAIN, identifier)}) is not None:
if (
registry.async_get_device_by_identifier((DOMAIN, identifier), config_entry_id)
is not None
):
return identifier
return adopted_identifier(panel_serial, adopted_anchor(device))

Expand Down Expand Up @@ -403,7 +419,9 @@ def async_register_adopted_devices(
"""
registry = dr.async_get(hass)
for device in snapshot.adopted_devices:
identifier = resolve_identifier(registry, snapshot.serial_number, device)
identifier = resolve_identifier(
registry, snapshot.serial_number, device, config_entry_id=entry_id
)
registry.async_get_or_create(
config_entry_id=entry_id,
**adopted_device_info(identifier, device, panel_device_id=panel_device_id),
Expand Down Expand Up @@ -855,6 +873,7 @@ def create_adopted_sensors(
snapshot: SpanPanelSnapshot,
registry: DeviceRegistry,
*,
config_entry_id: str,
panel_device_id: str,
overlay: CurationOverlay,
) -> list[AdoptedSensor]:
Expand All @@ -869,6 +888,7 @@ def create_adopted_sensors(
snapshot,
registry,
Platform.SENSOR,
config_entry_id=config_entry_id,
panel_device_id=panel_device_id,
overlay=overlay,
)
Expand All @@ -879,6 +899,7 @@ def create_adopted_binary_sensors(
snapshot: SpanPanelSnapshot,
registry: DeviceRegistry,
*,
config_entry_id: str,
panel_device_id: str,
overlay: CurationOverlay,
) -> list[AdoptedBinarySensor]:
Expand All @@ -889,6 +910,7 @@ def create_adopted_binary_sensors(
snapshot,
registry,
Platform.BINARY_SENSOR,
config_entry_id=config_entry_id,
panel_device_id=panel_device_id,
overlay=overlay,
)
Expand All @@ -899,6 +921,7 @@ def create_adopted_switches(
snapshot: SpanPanelSnapshot,
registry: DeviceRegistry,
*,
config_entry_id: str,
panel_device_id: str,
overlay: CurationOverlay,
) -> list[AdoptedSwitch]:
Expand All @@ -909,6 +932,7 @@ def create_adopted_switches(
snapshot,
registry,
Platform.SWITCH,
config_entry_id=config_entry_id,
panel_device_id=panel_device_id,
overlay=overlay,
)
Expand All @@ -919,6 +943,7 @@ def create_adopted_selects(
snapshot: SpanPanelSnapshot,
registry: DeviceRegistry,
*,
config_entry_id: str,
panel_device_id: str,
overlay: CurationOverlay,
) -> list[AdoptedSelect]:
Expand All @@ -929,6 +954,7 @@ def create_adopted_selects(
snapshot,
registry,
Platform.SELECT,
config_entry_id=config_entry_id,
panel_device_id=panel_device_id,
overlay=overlay,
)
Expand All @@ -939,6 +965,7 @@ def create_adopted_numbers(
snapshot: SpanPanelSnapshot,
registry: DeviceRegistry,
*,
config_entry_id: str,
panel_device_id: str,
overlay: CurationOverlay,
) -> list[AdoptedNumber]:
Expand All @@ -949,6 +976,7 @@ def create_adopted_numbers(
snapshot,
registry,
Platform.NUMBER,
config_entry_id=config_entry_id,
panel_device_id=panel_device_id,
overlay=overlay,
)
Expand All @@ -961,6 +989,7 @@ def _create[AdoptedT: AdoptedEntity](
registry: DeviceRegistry,
platform: Platform,
*,
config_entry_id: str,
panel_device_id: str,
overlay: CurationOverlay,
) -> list[AdoptedT]:
Expand Down Expand Up @@ -1006,7 +1035,7 @@ def _create[AdoptedT: AdoptedEntity](
"""
built: list[AdoptedT] = []
claimed: dict[str, str] = {}
for device, identifier in _adopted(snapshot, registry):
for device, identifier in _adopted(snapshot, registry, config_entry_id):
for declaration in sorted(device.properties, key=lambda row: row.path):
if classify(declaration) is not platform:
continue
Expand Down Expand Up @@ -1043,10 +1072,15 @@ def _create[AdoptedT: AdoptedEntity](


def _adopted(
snapshot: SpanPanelSnapshot, registry: DeviceRegistry
snapshot: SpanPanelSnapshot, registry: DeviceRegistry, config_entry_id: str
) -> list[tuple[AdoptedDevice, str]]:
"""Each adopted device paired with the identifier this install uses for it."""
return [
(device, resolve_identifier(registry, snapshot.serial_number, device))
(
device,
resolve_identifier(
registry, snapshot.serial_number, device, config_entry_id=config_entry_id
),
)
for device in snapshot.adopted_devices
]
2 changes: 2 additions & 0 deletions custom_components/span_panel/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,7 @@ async def async_setup_entry(
coordinator,
snapshot,
dr.async_get(hass),
config_entry_id=config_entry.entry_id,
panel_device_id=config_entry.runtime_data.panel_device_id,
overlay=config_entry.runtime_data.curation,
),
Expand All @@ -702,6 +703,7 @@ async def async_setup_entry(
snapshot,
dr.async_get(hass),
er.async_get(hass),
config_entry_id=config_entry.entry_id,
overlay=config_entry.runtime_data.curation,
),
]
Expand Down
53 changes: 43 additions & 10 deletions custom_components/span_panel/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ def create_extension_sensors(
device_registry: DeviceRegistry,
entity_registry: EntityRegistry,
*,
config_entry_id: str,
overlay: CurationOverlay,
) -> list[ExtensionSensor]:
"""Every extension property that is not a declared boolean."""
Expand All @@ -536,6 +537,7 @@ def create_extension_sensors(
device_registry,
entity_registry,
Platform.SENSOR,
config_entry_id=config_entry_id,
overlay=overlay,
)

Expand All @@ -546,6 +548,7 @@ def create_extension_binary_sensors(
device_registry: DeviceRegistry,
entity_registry: EntityRegistry,
*,
config_entry_id: str,
overlay: CurationOverlay,
) -> list[ExtensionBinarySensor]:
"""Every extension property declared `boolean`."""
Expand All @@ -556,6 +559,7 @@ def create_extension_binary_sensors(
device_registry,
entity_registry,
Platform.BINARY_SENSOR,
config_entry_id=config_entry_id,
overlay=overlay,
)

Expand All @@ -568,6 +572,7 @@ def _create[ExtensionT: ExtensionEntity](
entity_registry: EntityRegistry,
platform: Platform,
*,
config_entry_id: str,
overlay: CurationOverlay,
) -> list[ExtensionT]:
"""Build one platform's share of the extension properties.
Expand All @@ -592,7 +597,9 @@ def _create[ExtensionT: ExtensionEntity](
type system holding the two to one contract, not a case that occurs.
"""
built: list[ExtensionT] = []
for row, unique_id, device_identifier in adoptable(snapshot, device_registry, entity_registry):
for row, unique_id, device_identifier in adoptable(
snapshot, device_registry, entity_registry, config_entry_id=config_entry_id
):
if resolve_platform(entity_registry, unique_id, row.datatype) is not platform:
continue
key = extension_curation_key(row.subject, row.path)
Expand Down Expand Up @@ -623,14 +630,16 @@ def adoptable(
snapshot: SpanPanelSnapshot,
device_registry: DeviceRegistry,
entity_registry: EntityRegistry,
*,
config_entry_id: str,
) -> list[tuple[ExtensionProperty, str, str]]:
"""Every extension property that can become an entity, with its id and card.

Three reasons a declared property is declined here, all of them stated rather
than silent: its subject resolves to no device card, its card is not in the
registry yet, or its address is outside the Homie charset. The first two are
ordinary states on a setup that raced a capability -- the entity appears on
the next reload, as capability-gated platforms already do.
than silent: its subject resolves to no device card, its card is not in this
entry's registry yet, or its address is outside the Homie charset. The first
two are ordinary states on a setup that raced a capability -- the entity
appears on the next reload, as capability-gated platforms already do.

**An id the registry already holds is never displaced by the cap.** The cap
admits rows in the order the adapter emitted them, and that order tracks the
Expand All @@ -643,13 +652,15 @@ def adoptable(
everything already registered is admitted first, and the cap applies only to
what is new.
"""
return _partition(snapshot, device_registry, entity_registry)[0]
return _partition(snapshot, device_registry, entity_registry, config_entry_id)[0]


def declined_extensions(
snapshot: SpanPanelSnapshot,
device_registry: DeviceRegistry,
entity_registry: EntityRegistry,
*,
config_entry_id: str,
) -> dict[str, int]:
"""How many properties each wire device declared beyond the cap.

Expand All @@ -658,22 +669,42 @@ def declined_extensions(
partition, and a warning per platform would double-count in the log while
saying nothing new.
"""
return _partition(snapshot, device_registry, entity_registry)[1]
return _partition(snapshot, device_registry, entity_registry, config_entry_id)[1]


def _partition(
snapshot: SpanPanelSnapshot,
device_registry: DeviceRegistry,
entity_registry: EntityRegistry,
config_entry_id: str,
) -> tuple[list[tuple[ExtensionProperty, str, str]], dict[str, int]]:
"""Split the declared properties into what is adopted and what the cap declined."""
"""Split the declared properties into what is adopted and what the cap declined.

The card is looked up within `config_entry_id` rather than across every entry,
for the reason `util.py` gives at its own head: identifiers are unique inside a
config entry and nowhere else. The unscoped question could be answered by a
device another entry owns, and the row would then be admitted as though its
card existed. It would not land on that device -- the registry files an
entity's device within the entity's own entry -- but on a card minted for it
here, carrying nothing but the identifier: the card of its own an extension
entity must never mint, reached around the deferral that exists to prevent it.

A second SPAN entry cannot hold this panel's identifiers today, because the
config flow keys entries on the serial every identifier embeds. That is a
property of the flow rather than of the registry, and the lookup does not
rest on it. Home Assistant reports the unscoped lookup as deprecated for this
ambiguity and says it stops working in 2027.8.
"""
known: list[tuple[ExtensionProperty, str, str]] = []
fresh: list[tuple[ExtensionProperty, str, str]] = []
for row in snapshot.extension_properties:
identifier = extension_device_identifier(snapshot.serial_number, row.subject)
if identifier is None:
continue
if device_registry.async_get_device(identifiers={(DOMAIN, identifier)}) is None:
if (
device_registry.async_get_device_by_identifier((DOMAIN, identifier), config_entry_id)
is None
):
_LOGGER.debug(
"Extension property %s has no registered device for %s yet; deferred to the next reload",
row.path,
Expand Down Expand Up @@ -755,7 +786,9 @@ async def async_notice_declined_extensions(
readings declined on the same one -- is news the user is told about, while a
translation change is not.
"""
declined = declined_extensions(snapshot, device_registry, entity_registry)
declined = declined_extensions(
snapshot, device_registry, entity_registry, config_entry_id=entry.entry_id
)
if not declined:
return
rendered = ", ".join(f"{key} ({count})" for key, count in sorted(declined.items()))
Expand Down
6 changes: 3 additions & 3 deletions custom_components/span_panel/frontend/dist/span-panel-card.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions custom_components/span_panel/frontend/dist/span-panel.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion custom_components/span_panel/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"span-panel-api-schema-0==1.1.2",
"span-panel-api-schema-1==1.1.3"
],
"version": "2.1.1",
"version": "2.1.2",
"zeroconf": [
{
"type": "_span._tcp.local."
Expand Down
1 change: 1 addition & 0 deletions custom_components/span_panel/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ async def async_setup_entry(
coordinator,
snapshot,
dr.async_get(hass),
config_entry_id=config_entry.entry_id,
panel_device_id=config_entry.runtime_data.panel_device_id,
overlay=config_entry.runtime_data.curation,
)
Expand Down
1 change: 1 addition & 0 deletions custom_components/span_panel/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ async def async_setup_entry(
coordinator,
coordinator.data,
dr.async_get(hass),
config_entry_id=config_entry.entry_id,
panel_device_id=config_entry.runtime_data.panel_device_id,
overlay=config_entry.runtime_data.curation,
)
Expand Down
2 changes: 2 additions & 0 deletions custom_components/span_panel/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ async def async_setup_entry(
coordinator,
snapshot,
dr.async_get(hass),
config_entry_id=config_entry.entry_id,
panel_device_id=config_entry.runtime_data.panel_device_id,
overlay=config_entry.runtime_data.curation,
)
Expand All @@ -139,6 +140,7 @@ async def async_setup_entry(
snapshot,
dr.async_get(hass),
er.async_get(hass),
config_entry_id=config_entry.entry_id,
overlay=config_entry.runtime_data.curation,
)

Expand Down
Loading