Skip to content
Closed
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,21 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
`panel_size` was already in that attribute block. If you have a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')`, point it at
the Wi-Fi Link binary sensor instead. `panel_size` is unaffected and stays where it is.

- **The three circuit energy sensors are renamed to match the ids they are given.** "Produced Energy", "Consumed Energy" and "Net Energy" become **Energy
Produced**, **Energy Consumed** and **Energy Net**, the order used by the `energy_produced`, `energy_consumed` and `energy_net` suffixes that these sensors'
unique ids carry and that new entities are given. **Entity ids, unique ids and history are unchanged**; only the name shown in the UI reorders.

### Fixed

- **Recreate entity IDs proposes the ids your panel would produce now.** Renaming a circuit in the SPAN app used to leave the button offering each entity the id
it already had, so it looked like it did nothing (#252). The proposal was frozen at whatever the circuit was called when the entity was first created; it now
follows the panel. **It is still an offer you accept** — a rename in the SPAN app never moves a live entity id by itself, and unique ids and statistics are
untouched. Circuit-numbers installations are unchanged: there the display name written by name sync is also what Home Assistant builds the proposal from, so
the button behaves exactly as it did.
- **Only circuits you actually renamed are offered.** Installations old enough to predate the current suffixes carry entity ids ending `_consumed_energy`,
`_produced_energy`, `_net_energy` or `_current_power`, where an entity created today would end `_energy_consumed`, `_energy_produced`, `_energy_net` or
`_power`. Those ids keep the suffix they have. Renormalising them would have offered a rename for **every circuit on the panel** — seventy-four on one we
measured — burying the one circuit that had actually been renamed and breaking the dashboards and automations of anyone who accepted.
- **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed
"Possible states: Unknown".
- **The README described Battery Power's sign backwards.** The sensor reports **discharging** as positive and always has — that is what release 2.0.5
Expand Down
52 changes: 28 additions & 24 deletions custom_components/span_panel/entity_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
build_select_unique_id,
build_switch_unique_id,
construct_synthetic_unique_id,
preserve_legacy_entity_id_suffix,
)
from .util import snapshot_to_device_info

Expand Down Expand Up @@ -284,44 +285,41 @@ def construct_single_circuit_entity_id(
platform: str,
suffix: str,
circuit_data: SpanCircuitSnapshot,
unique_id: str | None = None,
device_name: str | None = None,
existing_entity_id: str | None = None,
) -> str | None:
"""Construct entity ID for single-circuit sensors.
"""Construct the entity ID the current panel data and naming flags produce.

Always computed, never read back from the registry -- including for a circuit
that already has entities. Home Assistant treats an entity_id set before the
entity is added as a *suggestion*: `EntityPlatform` splits it into
`suggested_object_id`, hands that to `async_get_or_create`, and for a
unique_id already on file that call routes to `_async_update_entity` with no
`new_entity_id`, then reassigns `entity.entity_id` from the stored entry. A
live entity ID therefore cannot move from here; only the stored suggestion
changes.

That suggestion is the field "Recreate entity IDs" regenerates from when the
registry holds no user `name` override, which in friendly-names mode is
always the case -- the panel name reaches the UI as `original_name`. Handing
back the stored entity ID froze the suggestion at whatever the circuit was
called on the day it was added, so a circuit renamed in the SPAN app was
offered its own ID and Recreate looked broken (issue #252).

Args:
coordinator: The coordinator instance
snapshot: The panel snapshot data
platform: Platform name ("sensor", "switch", "select")
suffix: Entity-specific suffix ("power", "energy_produced", etc.)
circuit_data: Circuit data object
unique_id: The unique ID for this entity (None to skip registry lookup)
device_name: Device name for entity ID construction (None to use from config entry)
existing_entity_id: This entity's id in the registry, when it has one, so an
id predating the suffix mapping keeps the suffix it shipped with

Returns:
Constructed entity ID string or None if device info unavailable

"""
# Check registry first only if unique_id is provided
if unique_id is not None:
entity_registry = er.async_get(coordinator.hass)
existing_entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id)

_LOGGER.debug(
"Circuit helper registry lookup - unique_id=%s, found_entity_id=%s",
unique_id,
existing_entity_id,
)

if existing_entity_id:
return existing_entity_id
# FATAL ERROR: Expected unique_id not found in registry
raise ValueError(
f"REGISTRY LOOKUP ERROR: Expected unique_id '{unique_id}' not found in registry. "
f"This indicates a migration or configuration mismatch."
)
_LOGGER.debug("Circuit helper - no unique_id provided, skipping registry lookup")

# Get device info
device_info = snapshot_to_device_info(snapshot, device_name)
if not device_info or not device_info.get("name"):
Expand Down Expand Up @@ -372,7 +370,13 @@ def construct_single_circuit_entity_id(
if suffix and not circuit_part.endswith(f"_{suffix}"):
parts.append(suffix)

return f"{platform}.{'_'.join(parts)}"
# An entity created before the suffix mapping reached entity ids carries the
# older spelling. Renormalising it would offer a rename to every circuit on
# the panel, so the circuit-name half follows the panel and the suffix half
# stays as it shipped.
return preserve_legacy_entity_id_suffix(
f"{platform}.{'_'.join(parts)}", existing_entity_id, suffix
)


def construct_unmapped_entity_id(
Expand Down
90 changes: 78 additions & 12 deletions custom_components/span_panel/id_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@
"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style.

Every entry here translates a legacy camelCase description key into the suffix
its entities have carried since before 2.0.8 -- and that suffix is shared by the
`unique_id` *and* the `entity_id`, so a changed entry moves both on every
installed panel. A moved `unique_id` costs the statistics; a moved `entity_id`
breaks the templates and automations a user wrote.
its entities have carried since before 2.0.8, so a changed entry moves a live
`unique_id` on every installed panel, and a moved `unique_id` costs the
statistics.

It governs the `entity_id` too, but only for entities created since the
integration began presetting one. Older entities took their id from the
descriptor name instead, which used the opposite word order -- that is what
`LEGACY_ENTITY_ID_SUFFIXES` records, and why it has to exist.

So the rule for anything new is **verbatim**: a description key added from here on
resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`,
Expand All @@ -50,6 +54,60 @@
"""


# Entity-id suffixes that predate the mapping above, keyed by the suffix that
# replaced them.
LEGACY_ENTITY_ID_SUFFIXES: dict[str, frozenset[str]] = {
"power": frozenset({"current_power"}),
"energy_produced": frozenset({"produced_energy"}),
"energy_consumed": frozenset({"consumed_energy"}),
"energy_net": frozenset({"net_energy"}),
}
"""Entity ids only -- never a `unique_id`, which has always used the canonical form.

Before the integration preset an `entity_id`, Home Assistant composed one from the
descriptor name: "Consumed Energy" gave `..._consumed_energy` where the mapping
above says `energy_consumed`. Installs from that era carry an entity id whose
suffix disagrees with their own unique id; the two orders were only reconciled
going forward.

That disagreement is not a defect to correct on a user's behalf. Renormalising it
offers a rename for every circuit on the panel -- seventy-four on a measured one --
which buries the circuit they actually renamed and breaks every dashboard and
automation belonging to anyone who accepts. So an existing entity keeps the suffix
it has; only the circuit-name half of its id follows the panel.

Entries are historical fact, so this table only grows by discovering another form
that shipped. `energy_imported`, `energy_exported`, `priority`, `current` and
`breaker_rating` have no entry because they were never named the other way round.
"""


def preserve_legacy_entity_id_suffix(
computed_entity_id: str, existing_entity_id: str | None, suffix: str
) -> str:
"""Return the id to use, keeping an existing entity's legacy suffix form.

The ids are compared with the suffix removed, so the existing id wins only
when the circuit-name half already agrees and the suffix is a known older
spelling. A circuit renamed on the panel differs in that half and gets the
computed id, which is what issue #252 is about.
"""
if not existing_entity_id or existing_entity_id == computed_entity_id:
return computed_entity_id

legacy_forms = LEGACY_ENTITY_ID_SUFFIXES.get(suffix)
if not legacy_forms:
return computed_entity_id

stem = computed_entity_id.removesuffix(f"_{suffix}")
if stem == computed_entity_id:
return computed_entity_id

if any(existing_entity_id == f"{stem}_{form}" for form in legacy_forms):
return existing_entity_id
return computed_entity_id


# Panel sensor API field mappings (used by get_user_friendly_suffix)
# Includes main meter/feedthrough produced, consumed, and net energy
PANEL_SUFFIX_MAPPING = {
Expand All @@ -70,10 +128,14 @@
"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style.

Every entry here translates a legacy camelCase description key into the suffix
its entities have carried since before 2.0.8 -- and that suffix is shared by the
`unique_id` *and* the `entity_id`, so a changed entry moves both on every
installed panel. A moved `unique_id` costs the statistics; a moved `entity_id`
breaks the templates and automations a user wrote.
its entities have carried since before 2.0.8, so a changed entry moves a live
`unique_id` on every installed panel, and a moved `unique_id` costs the
statistics.

It governs the `entity_id` too, but only for entities created since the
integration began presetting one. Older entities took their id from the
descriptor name instead, which used the opposite word order -- that is what
`LEGACY_ENTITY_ID_SUFFIXES` records, and why it has to exist.

So the rule for anything new is **verbatim**: a description key added from here on
resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`,
Expand Down Expand Up @@ -107,10 +169,14 @@
"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style.

Every entry here translates a legacy camelCase description key into the suffix
its entities have carried since before 2.0.8 -- and that suffix is shared by the
`unique_id` *and* the `entity_id`, so a changed entry moves both on every
installed panel. A moved `unique_id` costs the statistics; a moved `entity_id`
breaks the templates and automations a user wrote.
its entities have carried since before 2.0.8, so a changed entry moves a live
`unique_id` on every installed panel, and a moved `unique_id` costs the
statistics.

It governs the `entity_id` too, but only for entities created since the
integration began presetting one. Older entities took their id from the
descriptor name instead, which used the opposite word order -- that is what
`LEGACY_ENTITY_ID_SUFFIXES` records, and why it has to exist.

So the rule for anything new is **verbatim**: a description key added from here on
resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`,
Expand Down
2 changes: 1 addition & 1 deletion custom_components/span_panel/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"span-panel-api-schema-0==1.0.0",
"span-panel-api-schema-1==1.0.0"
],
"version": "2.1.0b9",
"version": "2.1.0b11",
"zeroconf": [
{
"type": "_span._tcp.local."
Expand Down
8 changes: 4 additions & 4 deletions custom_components/span_panel/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,16 +166,16 @@ def __init__(
self._attr_name = None

# Explicitly set entity_id using construct_single_circuit_entity_id
# which correctly handles 240V two-tab circuits.
# Only pass unique_id for existing entities (registry lookup);
# for new entities pass None to get the constructed default.
# which correctly handles 240V two-tab circuits. For an entity already
# in the registry this is a suggestion HA records and does not act on --
# the stored entity_id stands. See the helper's docstring.
constructed_id = construct_single_circuit_entity_id(
coordinator,
snapshot,
"select",
description.entity_description.key,
circuit,
unique_id=self._attr_unique_id if existing_entity_id else None,
existing_entity_id=existing_entity_id,
)
if constructed_id:
self.entity_id = constructed_id
Expand Down
6 changes: 5 additions & 1 deletion custom_components/span_panel/sensor_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,14 @@ def _construct_entity_id(
Subclasses may override to use entity_id helpers from helpers.py.
Returns None to let HA auto-generate from _attr_name.

The value is what current panel data and the naming flags produce; an
existing id is not consulted to decide *whether* to compute one, only so
that an id predating the suffix mapping keeps the suffix it shipped with.

Args:
snapshot: The panel snapshot data
description: The sensor description
existing_entity_id: The existing entity_id from registry, or None for new entities
existing_entity_id: This entity's id in the registry, or None if new

"""
return None
Expand Down
4 changes: 2 additions & 2 deletions custom_components/span_panel/sensor_circuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ def _construct_entity_id(
"sensor",
suffix,
circuit,
unique_id=self._attr_unique_id if existing_entity_id else None,
existing_entity_id=existing_entity_id,
)

def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot:
Expand Down Expand Up @@ -408,7 +408,7 @@ def _construct_entity_id(
"sensor",
suffix,
circuit,
unique_id=self._attr_unique_id if existing_entity_id else None,
existing_entity_id=existing_entity_id,
)

# Map original_key to the energy type used for coordinator dip offset tracking
Expand Down
8 changes: 4 additions & 4 deletions custom_components/span_panel/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,16 +120,16 @@ def __init__(
super().__init__(coordinator)

# Explicitly set entity_id using construct_single_circuit_entity_id
# which correctly handles 240V two-tab circuits.
# Only pass unique_id for existing entities (registry lookup);
# for new entities pass None to get the constructed default.
# which correctly handles 240V two-tab circuits. For an entity already
# in the registry this is a suggestion HA records and does not act on --
# the stored entity_id stands. See the helper's docstring.
constructed_id = construct_single_circuit_entity_id(
coordinator,
snapshot,
"switch",
"breaker",
circuit,
unique_id=self._attr_unique_id if existing_entity_id else None,
existing_entity_id=existing_entity_id,
)
if constructed_id:
self.entity_id = constructed_id
Expand Down
Loading