Skip to content

release: 2.1.0 — SPAN firmware r202633 support, new devices, naming fixes - #257

Open
cayossarian wants to merge 119 commits into
mainfrom
fix/circuit-numbers-recreate
Open

release: 2.1.0 — SPAN firmware r202633 support, new devices, naming fixes#257
cayossarian wants to merge 119 commits into
mainfrom
fix/circuit-numbers-recreate

Conversation

@cayossarian

Copy link
Copy Markdown
Member

Release branch for 2.1.0, currently at 2.1.0b14. 118 commits, 100 files, +22,219 / −1,832 against main.

This collapses a three-deep branch chain into one PR. fix/circuit-numbers-recreate sits on fix/recreate-entity-ids, which sits on feat/discovery-and-catalog-validation; both are ancestors of this branch, so everything in them is included here. Supersedes #256, which targets the feature branch rather than main.

main holds no commit this branch lacks.

What is in it

Support for SPAN firmware r202633. The wire model every release up to 2.0.8 is retired in the same firmware update that introduces the new one, with no overlap and no setting to keep the old behaviour — 2.0.8 cannot read a panel on r202633. The integration detects the change on the wire, reloads itself, notifies, and carries on. Entity IDs, unique IDs and statistics survive the transition.

Raises the Home Assistant floor to 2026.8.0. Two device-registry calls were replaced in 2026.8 and their replacements do not exist in 2026.5–2026.7, so no single build runs on both.

New devices and entities — the Microgrid Interconnect Device with Grid State, a device of its own for the solar inverter, EVSE Charge Current Limit, panel-link binary sensors, BESS meter power and communication state, shed forecasts, and the import-limit/PCS diagnostics.

Vendor extensibility. A device type nobody has modelled gets a card of its own with its properties beneath it, and a settable property becomes a real control. A new reading on a device that is already modelled appears on that device's card. Everything adopted arrives disabled and diagnostic, and nothing adopted enters long-term statistics.

Naming fixes. "Recreate entity IDs" proposes the IDs the panel would produce now (#252), in the naming style the installation already uses — a tab-named panel is never offered a conversion to friendly names, and IDs predating the current suffixes keep the suffix they have.

Five panel sensors are switched off for new installations because the SPAN API's values for them are unreliable (#234, pinned). Existing installations keep them, switched on, since the default applies only at first registration.

The full user-facing list is in CHANGELOG.md under 2.1.0.

Verification

  • 1269 tests passing.
  • Full prek set green: ruff, mypy strict, vulture, bandit, radon, markdownlint, translation sync, dependency-pin sync.
  • Pinned to span-panel-api==3.0.1 with schemas at 1.0.0, both released.
  • Pre-release v2.1.0b14 is published for testing and carries the 2.1.0 changelog as its notes.

Before merging

Two Dependabot PRs (#251, #248) target main and will need a rebase after this lands; #248 touches pyproject.toml, which this branch also changes.

The availability guard was `if gfe == "GRID": return False` -- do not offer the
override when the panel already reports grid-connected. Under v1.0 that compares
`None == "GRID"` and never fires: `dominant_power_source` is one of the fields the
schema split, so the guard is dead code expressing a live intention.

Reading `dsm_state` instead fixes it and is the better question anyway. "Are we
already on the grid" is what `dsm_state` answers directly; the grid-forming entity
answers it only by implication. And `dsm_state` is populated on both wire schemas --
flat derives it, v1.0 reads it from the MID -- so the guard stays free of any
knowledge of which schema is underneath. Checking one field on one schema and a
different field on the other would put the migration back above the adapter seam,
which is the thing the seam exists to prevent.

One useful consequence: the precedence behind `dsm_state` folds in the user's own
`shed/asserted-islanding-state`, so once a press takes effect the button disables
itself rather than inviting a second.

The first guard is unchanged. `battery.connected` is populated on both schemas, and it
mirrors what firmware itself enforces -- the enclosure model accepts an assertion only
"while the enclosure has lost or degraded communication with its MID / BESS".

Falsified: reverting the comparison to `dominant_power_source == "GRID"` fails all
three availability tests.

665 passed.
… sensor

v1.0 knows *which* device is forming the grid, which flat never did. The state stays
the source class -- GRID / BATTERY / PV -- because that is the closed enum automations
compare against and it must not change. The identity goes on an attribute instead:
it refines a value already on screen, adds no entity-list noise, and cannot break an
automation that never referenced it.

Deliberately the display name, not the wire id. `sim-40t-001-SIM-BESS-40T-001` is a
Homie device id, not a Home Assistant one, and an opaque string on a dashboard is worse
than none. The id stays in the snapshot, where it is worth having for correlation and
because it lands in a diagnostics dump -- which is how #214 was actually
solved.

Keyed on the GFE sensor specifically: `extra_state_attributes` here is shared by every
panel-status sensor, so an unguarded addition would attach it to all of them.

Reads `None` for now. `mid` arrived in span-panel-api 3.0.0b3 and this integration pins
2.6.4, so `getattr` degrades cleanly -- which is also the right answer on any flat panel,
since no flat firmware publishes a MID at all.

Falsified: returning the wire id instead of the display name fails the test named for
that distinction.

668 passed.
BREAKING for the library boundary, invisible to users. `product_name` is retired on
`battery`, `evse` and `pv`; `model` is the human designation and `part_number` the SKU,
on all three. Flat was the inconsistent side -- it put the SKU in `bess/model` and in
`evse/part-number` for the same concept -- and `schema_0` now translates flat into the
normalised shape rather than mirroring it, so both wire schemas hand this integration
the same fields with the same values.

Seven source sites and eight test sites. The one that mattered is `util.py`: the
device-registry `model` for the BESS and EVSE cards is built from this field, and the
delta document flagged that leaving it would blank those cards. Checked rather than
assumed -- `bess_device_info` renders "SPAN Battery", `evse_device_info` renders
"SPAN Drive", and the "Battery Storage" fallback still fires on an empty snapshot.

`schema_expectations.py` moves with them, since it names snapshot paths.

The dev source override points at the schema-1 worktree rather than the sibling symlink,
which resolves to the primary checkout on 2.6.4 -- this branch cannot build against that.
The pin names 3.0.0b3, which is not published yet, so this branch is ahead of a release
by design.

668 passed, mypy clean. The 91 ruff findings in tests/ are pre-existing and unchanged --
identical count with this work stashed.
…one new entity

v1.0 publishes a Microgrid Interconnect Device and puts the `grid` capability on it
rather than on the enclosure -- "the enclosure device itself does not publish them" --
so islanding decisions belong to hardware with an identity of its own. The library
started carrying it in 3.0.0b3; this registers it as a Home Assistant device and gives
it the one fact that is genuinely new.

**Only `grid_state`.** Utility-supply health -- UP / DOWN / DEGRADED -- has no flat
equivalent. Islanding state and the grid-forming entity are deliberately *not*
duplicated onto this device: both already reach a user as `dsm_state` and
`grid_forming_entity` on the panel, and those must keep their ids and their history.
Showing a user the same fact twice is not the benign cell of the absorb-or-surface
policy; adding a fact nobody had is.

**Gated on what the snapshot carries, never on a version or a flag.** This integration
has to serve flat and parent/child panels side by side until every panel has hot-loaded
v1.0, and a flat panel publishes no MID at all -- so `has_mid` is false everywhere today
and nothing a user has changes. A panel that hot-loads v1.0 mid-life gains `mid` as a
capability, the coordinator reloads, and the device appears; one that never does never
sees it.

Every such branch is marked `DUAL-SCHEMA` and a test asserts the exact set of modules
carrying that marker. Asserted rather than left to convention because the failure mode
is silent: a later addition that assumed parent/child would work on a developer's panel
and break on everyone else's, with nothing to say so. When flat support is finally
dropped, grepping the token finds every conditional to make unconditional.

`has_mid` needs no sentinel, unlike `has_bess`, which infers presence from
`soe_percentage is not None` because the battery field is always there. The library made
`mid` optional precisely so presence is unambiguous.

`via_device` is the panel, matching BESS and EVSE, even though the wire tree makes the
MID a child of the BESS. Home Assistant's device graph is what a user navigates and
every SPAN sub-device hangs off the panel there; mirroring Homie parentage would put the
MID a level deeper than its siblings for no reader's benefit.

Also drops the `getattr` from `_grid_forming_device_name`: with the pin at 3.0.0b3 the
field always exists, so the remaining check is about what the panel publishes rather
than which library is installed.

Translations for all five locales, since the enum states render to users.

676 passed, mypy clean. Falsified: removing the `has_mid` gate fails both flat-panel
tests.
…trap

`manifest.json` still said `span-panel-api==2.6.4` after the move to 3.0.0b3. Only
`pyproject.toml` had been updated, and that is dev tooling -- **manifest.json is the
list Home Assistant reads and installs from**, so the branch as it stood would have
installed the old bootstrap and found no schema-1 adapter at all.

It also needed more than a version bump. The schema packages are *not* dependencies of
`span-panel-api`; they are separate distributions found through the
`span_panel_api.schema_adapters` entry-point group, so each has to be installed for
discovery to see it. Installing the bootstrap alone yields zero adapters and a panel
that cannot be parsed on either schema.

    span-panel-api==3.0.0b3
    span-panel-api-schema-0==1.0.0b3
    span-panel-api-schema-1==0.1.0b3

Verified rather than assumed -- after syncing, `discover_adapters()` resolves both
`schema_0` and `schema_1`.

Flat panels need schema-0 exactly as much as v1.0 panels need schema-1, so this is not
a v1.0-only addition: it is what makes dual-schema support installable at all.

676 passed.
…ross it

The library now rebuilds its parser when a panel returns as a different schema
generation, which restores *reading*. It cannot restore *topology*: devices and
entities are built once in `async_setup_entry` from the tree as it looked then. v1.0
adds a MID the flat tree has no equivalent for and re-keys the EVSEs, so a live
upgrade left the panel reading correctly while still showing the old device set, and
a manual reload was needed to see anything new.

The coordinator now subscribes to the library's schema-change callback and reloads
the entry itself, with a persistent notification saying why. The reload is scheduled
rather than awaited: it runs inside the client's own callback fan-out, and reloading
tears that client down.

**Grid Islandable survives instead of orphaning.** Flat publishes
`core/grid-islandable`; v1.0 publishes nothing equivalent, deliberately --
`devices/bess.md` says a consumer reads backup capability from the capability set,
"a MID `grid` child means premises-segment backup", and "there is no single
'islanded?' bit to reconcile". So under v1.0 the sensor reads MID presence, which is
the classifier the spec nominates. That is a consumer deriving a convenience from
what is published, not a producer inventing a claim.

Without it the entity went `unavailable` with `restored: true` after an upgrade,
which reaches a user as a sensor that broke rather than one whose source moved.

A repair issue carries the part that a derived value cannot: `grid_islandable`
answers "could this panel island" while the MID answers "is it islanding right now",
and an automation written against the former on flat firmware may well have meant the
latter. A repair rather than a notification because it persists until acted on, which
suits a change the user has to make in their own automations. Raised only on upgrades
to v1.0 — panel firmware does not roll back, so the reverse happens solely in the
upgrade rehearsal.
Nine enum sensors declared `options=["unknown"]` and nothing else, so a panel sitting
at `dsm_on_grid` rendered as "Possible states: Unknown" — an entity reporting a live
state it said was impossible. Home Assistant validates the state against `options`
and renders that list as the entity's possible states, so the declaration was wrong
in the one place a user can see it.

`sensor_base` tried to cover this at runtime, appending each value the first time it
was observed. That could not work even in principle: options would only ever list
states the panel had already reached, so `dsm_off_grid` stays absent until the day of
an actual outage, and two identical panels advertise different sets depending on what
each has lived through. `evse_status` makes it plainest — ten states, of which a
charger shows maybe three in normal service.

These are closed domains, known when the code is written, and `en.json` already
spells every one of them out. The declarations now say what the translations render.

The runtime append stays as a last resort so an undeclared value degrades to a shown
state rather than a ValueError, but it logs at warning now: reaching it means the
panel published outside the enum its own catalog declares. That is a producer defect
of exactly the kind found this week on the emitter side — `grid-state` carrying the
islanding vocabulary, `relay-requester` inventing `BACKUP` and `NEVER` — and this is
the same class caught from the consumer end.

`test_enum_sensor_options.py` derives its expectations from the translations rather
than a second hand-maintained list, and covers all eight sensor groups so a new enum
is checked the day it lands. Set equality in both directions: an option with no
translation reaches a user as a raw key, and a translated state missing from options
is one Home Assistant will reject when the panel reports it.

Four EVSE tests asserted the seeded `["unknown"]` — they pinned the bug rather than a
contract, and now assert the states those sensors can actually report.

696 passed; all hooks green.
…claimed

The repair told users to point their automations at "the MID device's grid entities".
That advice was wrong twice: the MID deliberately carries only `grid-state`, and the
islanding state those automations want never moved off the panel.

`sensor.*_dsm_grid_state` keeps its entity id and its history across the upgrade, and
gets *more* trustworthy. Under flat, `schema_0` derived it — the battery's
`grid-state` when one was commissioned, otherwise an inference from
`dominant-power-source` and whether any power was crossing the grid connection. Under
v1.0 it reads the islanding state the MID actually senses. That is the heuristic v1.0
exists to retire, retired, and it is an improvement a user should be told about rather
than a migration they must survive.

So the text now says what is true: nothing you depend on has gone away, no automation
changes are needed, `Grid Islandable` still answers the same question via MID
presence, and a new MID device reports the health of the utility supply — which flat
did not report at all.

The key is renamed `panel_upgraded_to_ebus_v1`. `grid_islandable_moved_to_mid`
described the belief rather than the behaviour, and nothing moved to the MID; a key is
what the next reader greps for, so a misleading one outlives the text that explained
it.

Retitled and rewritten in all five locales, and the method docstring rewritten with
it — it carried the same wrong claim, in more detail.

696 passed; all hooks green.
Home Assistant deprecated `via_device=(DOMAIN, serial)` and the unscoped
`async_get_device(identifiers=...)` together in 2026.8, for one reason:
device identifiers are unique only within a config entry, so anything
treating them as globally unique is ambiguous by construction. Both stop
working in 2027.8. Both fired on every startup.

`via_device_id` takes a registry id, which exists only once the panel device
does. Rather than have six call sites each hit the registry, setup resolves
it at the one place the device is known to exist -- `ensure_device_registered`
either just created it or just found it -- and carries it on
SpanPanelRuntimeData. That is what lets the three sub-device builders take a
plain `str` and leaves no caller with an absence to handle. runtime_data is
now built after registration rather than before.

Requires Home Assistant 2026.8: neither replacement exists in 2026.5.4, which
this repo pinned, so the floor moves and 2026.5-2026.7 are dropped. 2026.8.0
is the floor rather than 2026.8.1 because both APIs are already there,
verified against an installed 2026.8.0 rather than assumed. The test pins
follow it: pytest-homeassistant-custom-component pins one exact Home
Assistant per release, so `==0.13.354` is not a preference but the only
release that pins 2026.8.0 -- a `>=` there would imply a freedom that does
not exist. Testing at the declared floor is what catches an accidental
dependency on a later patch. Three minor versions of churn produced no
unrelated failures; every initial break was this change's own.

hacs.json's stated minimum was 2026.8.0 -- it claimed 2023.3.0, which the
`type` statements and runtime_data alone have made untrue for a long time.

Each deprecation is falsified separately in tests/test_device_links.py:
restoring `via_device` fails the builder test for all three sub-devices, and
restoring the unscoped lookup fails on adopting another entry's device. The
end-to-end registry test deliberately does not guard the deprecation -- Home
Assistant resolves the old form to the same id -- it guards the id being
right, which is the failure the registry swallows silently by dropping a
`via_device_id` it cannot resolve.
`_classify_sub_device` knew `bess` and `evse`, so the Microgrid Interconnect
reached the topology payload as `unknown` and a card rendered it as a device
with a name and no type.

Adding a third branch would have fixed this instance and left the mechanism.
The grammar -- `{panel serial}_{kind}`, with EVSE carrying a node id after
the kind -- was written by the builders in `util` and restated from memory by
the topology command, so the two ends could drift, and did, the moment a
third kind existed. It is now declared once beside the builders, which
compose from it, and read back through `classify_sub_device_identifier`.

The `_bess` and `_mid` tests tighten to suffix matches while they are being
moved; EVSE stays an infix because its node id follows.

The drift guard is parametrised over the same builder list as the link test,
so a fourth sub-device added to `util` without teaching the classifier fails
in CI rather than in a dashboard. Removing the MID branch fails it with
`mid identifier classifies as {None}`.

Card: a sub-device is skipped when it has no power reading, no charts and no
visible entities. Tested on emptiness rather than on type -- excluding the
MID by name would fix one device and leave the next to rediscover it, and
v1.0 has more classes coming. The MID qualifies today only because its one
entity is a diagnostic enum; opt that entity in and the tile returns with no
code change, which is what a test pins. Bundles rebuilt.
v2.0.8 is the last release; sixteen commits of v1.0 schema work have landed
since with no version to file them under, and `pyproject.toml` had drifted a
patch behind `manifest.json` besides. Both now read 2.1.0.

The entry leads with the Home Assistant requirement because it is the only
change here a user can be hurt by. 2026.8 replaced the two device-registry
calls this integration depends on and the replacements do not exist in
2026.5-2026.7, so no single release runs on both; anyone older stays on 2.0.8
and HACS will not offer them this. That is worth a sentence in a changelog
rather than a silent failure to update.

Everything else is additive or an improvement, and is written for someone
reading release notes rather than a diff: the MID device and its Grid State
entity, self-reload on a firmware schema change, `DSM Grid State` becoming
sensed rather than inferred, `Grid Islandable` surviving on MID presence, and
the enum sensors that used to advertise only "unknown". `battery.model`
changing value is called out, because it is the one existing entity whose
reading moves.

Emphasis in the new section uses underscores to match the file. MD049 is
first-wins, so an asterisk above the existing `_must_` lines silently made
every one of them a violation.
r202633 documents model, firmware-version and hardware-version on the MID's info
node. mid_device_info could set a model and a serial and nothing else, so a user
saw a Microgrid Interconnect card with no firmware row beside a battery that had
one -- the battery's identical property having been mapped from the start. The
library now carries both (span-panel-api SpanMidSnapshot.software_version /
hardware_version); this reads them.

Passed through unguarded, exactly as bess_device_info does. DeviceInfo omits a
None field and renders an empty string as a present-but-blank row, so `or ""`
would invent a firmware row reading empty for a panel that published nothing --
worse than no row, because it asserts the panel answered and the answer was
nothing. A test pins that distinction.

No schema conditional, deliberately: flat publishes no MID at all, so has_mid
keeps every caller of this builder off a flat panel and a branch here would be
unreachable code implying a case that cannot arise. The comment saying so
originally used the DUAL-SCHEMA token and was caught by
test_every_schema_conditional_is_findable, which asserts the exact set of modules
that branch on schema -- util.py does not, and the marker is a grep target rather
than prose. Reworded rather than widening the list.

Corrects a stale comment in the existing card test. It read "the producer
publishes no MID model today", which stopped being true when panelbench started
valuing info/model; that case is the fallback path now, not the only path.

PV is deliberately not included. The integration has no PV device -- PV metadata
reaches a user as three diagnostic sensors on the panel -- so consuming
SpanPVSnapshot.software_version would mean adding a fourth, which is a new
user-visible entity and a product decision rather than a mapping change.
Sources point at the span-panel-api-p3 worktree so this branch develops against
the field-metadata changes without disturbing the baseline pair.
The earlier pin commit updated manifest.json and the uv sources but left
[project] dependencies on b3 — inert locally because the sources override it,
operative on a clean resolve. requirements_test.txt is read by nothing; uv sync
reads [project] dependencies, so that is the file that had to change.
Retires SENSOR_FIELD_MAP. The declaration now lives on the object that reads
the field, so the two cannot drift. Also drops the key-based dedup, which
silently discarded descriptions sharing a key across device classes.
declared_field_paths() returned dict[str, bool] whose value was always False —
derived descriptions are skipped before the assignment, so no key could map to
True. It is a set, so return one.

Also splits the residual readers by empirical producibility against both
schema adapters. Readers both adapters produce join RESIDUAL_FIELD_PATHS;
$target echoes, topology-derived values and schema-conditional fields go to
RESIDUAL_DERIVED_PATHS and stay out of the producible set.

Adds the enumeration test that was missing: a description inheriting the
declaration mixin but setting neither field passed silently and was dropped
from every gate.
Only some of its entries are derived. Five are schema-conditional — read
straight off a snapshot field that one adapter publishes and the other does
not — so "derived" misdescribes why they are exempt. The docstring now names
both reasons a path lands here.
Fourteen further snapshot-field reads outside any entity description were in
neither bucket, including three in the same DeviceInfo call as bucketed mid.*
paths and one in the same availability property as battery.connected. That
adjacency is how the next reader concludes the constant is complete when it is
not.

All fourteen are exempt — none is producible by both adapters — so
declared_field_paths() stays at 46 and the gate is unchanged.
The gate in declared_field_paths() walks the binary sensor collections too,
so their declarations were producible-checked but never checked against
their readers. Cover them, and pin the introspected description set against
the gate's own so a new platform collection cannot be gated without being
verified.

An entity description class absent from the root-prefix map is now reported
as a failure rather than skipped -- a silent skip is the drift this test
exists to catch.
Removes the telemetry-based capability inference, checks unit-less sensors for
resolution, and reads field_metadata through the protocol rather than naming
the transport class.
Brings schema_validation and sensor_definitions to full statement coverage and
pins where the missing-declaration check is enforced.
SPAN firmware declares the circuit active-power property as kW while publishing
watts. The sibling lugs device declares W for the same quantity, the library
consumes the value unscaled, and our sensor declares W — so the label is wrong
and the reading is right. Left unhandled, every schema_0 panel would raise a
permanent unit mismatch its owner cannot act on.

KNOWN_BAD_SCHEMA_UNITS excuses only the exact declared unit, so a future
firmware declaring anything else for the same field is still reported.

Also: exclude RESIDUAL_EXEMPT_PATHS from `unread` (they are read, just not
required of both adapters — 10 of schema_0's 17 were mislabelled); drop the
`| None` arm of evaluate_field_metadata so the sentinel cannot be answered with
"healthy"; and evaluate both real adapter fixtures in the tests rather than only
synthetic single-entry dicts.
One issue per (entry, class, field path) so a dismissal cannot swallow a later
finding and one panel cannot clear another's, re-raised idempotently so
reconciliation never clears a dismissal. Issues are non-persistent: derived
from live state and re-asserted at startup from the tombstone that carries the
dismissal forward.

A produced field nothing reads stays a sanctioned addition — debug log only.

Adds async_remove_entry, which core does not provide, so a removed panel does
not leave its notices behind. Entity enumeration in a payload is a count plus
three examples, matched by the description's user-friendly suffix rather than
the snapshot field name.
Cross-checked every `name` in `strings.json` against the reference tables rather
than reading for plausibility. Sixty-one entity names, seven absent, five of
those label mismatches where the README says "BESS Firmware" for an entity
displayed as "Firmware Version". Two were real.

**The Microgrid Interconnect Device had no entity documentation at all** -- only a
Key Terms definition of what a MID is. Its `Grid State` sensor is the one
genuinely new reading on the v1.0 data model, since the previous firmware never
reported the state of the utility supply, and it is easy to confuse with `DSM
Grid State`, which is whether the *home* is islanded. The grid can be down while
the house runs happily off the battery. Now documented together so the
distinction is visible where someone would look for it.

**The circuit select is named "Circuit Priority"**, not "Circuit Shed Priority" as
the table said. A reader searching their entity list for the documented name
found nothing.

Also missing from the tables: EVSE Part Number entirely, and BESS Part Number.
Both are off by default, as are BESS Nameplate Capacity and PV Nameplate
Capacity, which the tables described without saying so -- the difference between
"I do not have this sensor" and "I have not switched it on".
`v2.1.0.b2` was cut before the diagnostics and documentation work, so b3 carries
four commits it does not have plus the library fix that matters most.

Pins move to `span-panel-api==3.0.0b8` and `span-panel-api-schema-1==0.1.0b7`;
schema-0 is unchanged at `1.0.0b5` because nothing in it moved. b8 is the release
where a panel answering 502 during its reboot is understood as "not ready yet"
rather than as a hard failure -- the path that cost a live upgrade its automatic
reload on two Home Assistant instances, and the one thing this release exists to
promise.

The pin propagation was done by `scripts/sync-dependencies.py` rather than by
hand, which is the first real use since it was repaired. It found and rewrote
both copies and failed the commit so they would be re-staged, which is what it
was always supposed to do and never once did.
b8's 502 handling shipped without its retry window: the exception was caught and
the loop still gave up after about twenty-three seconds, against a panel that
took four minutes to come back. b9 carries the widening -- twelve attempts
backing off to thirty seconds -- and a test that pins the total against the
observed reboot.

Still 2.1.0b3; the integration itself has not changed, only what it installs.
b10 stops the wait for a rebooting panel from giving up. The bound was wrong
twice for the same reason -- sized against a reboot somebody had measured, and
the next reboot was not that reboot -- and exhausting it meant stranded rather
than "try again later", because nothing starts the attempt again. It also
recognises four more mid-boot answers as "still starting" rather than as failure:
a connection reset mid-request, a gateway closing without replying, and a reply
that arrives truncated.

This release also catches that condition at setup. A panel that is not ready when
Home Assistant starts now produces a retrying `ConfigEntryNotReady` instead of a
dead entry needing a human -- which matters because the two events correlate: one
power interruption takes out both the house's electrical panel and the machine
watching it, and they race each other back up.

Pins propagated by `scripts/sync-dependencies.py`, third consecutive release it
has done correctly.
Observed on a live upgrade: the entity read `Off` on flat and went `Unavailable`
the moment the panel took r202633, on an install with no battery. Nothing about
the site changed.

This gate has now been narrowed twice for the same symptom. Gating on the flat
property alone left the entity unavailable after a v1.0 upgrade -- the panel had
not lost the capability, only the property that used to report it -- and its own
comment says so. Adding `has_mid` rescued panels that have a battery and left
every battery-less one exactly where it was, because it admitted the entity only
when the answer was going to be `True`.

For a boolean whose `False` is informative that is backwards. No MID is not
missing information, it is the information: `devices/bess.md` makes the signal
structural -- "a MID `grid` child means premises-segment backup ... neither means
no backup", and "there is no single 'islanded?' bit to reconcile" -- so a panel
without one does not island, and saying so is the answer rather than the absence
of one. `_grid_islandable` has always computed exactly that; the gate refused to
create the entity precisely when it would have said `False`.

Flat is untouched, which is the constraint that matters until the fleet moves. A
flat panel publishes the property, so the value comes from the panel as it always
has and MID presence is never consulted. A flat panel that stops publishing it
has a metadata row that fails to resolve, which is what makes the entity
unavailable and raises the Repair -- that path is deliberately left alone,
because it is the one that must not become a default presented as a reading.

Two wrong diagnoses on the way here, both discarded after reading rather than
shipped: the availability probe, which skips a field path with no metadata row at
all, and the `SCHEMA_CONDITIONAL_FIELD` labelling on three descriptions, which is
harmless for the same reason. It was the creation gate.

Marked `DUAL-SCHEMA` so the retirement is mechanical: nothing here is removed
when flat goes, since an unconditional append is already the end state -- the
branch that goes is the flat property read inside `_grid_islandable`.

Both mutations verified: withholding the v1.0 answer fails, and ignoring the flat
property fails.
Pins move to `span-panel-api==3.0.0b11` and `span-panel-api-schema-1==0.1.0b8`,
which carry the `dominant_power_source` fix; schema-0 stays at `1.0.0b5` because
nothing in it moved.

README gains the battery-less case in both places a reader would look: the Grid
Forming Entity deep dive now states that GFE is Grid on a panel with no battery
on both firmware generations, with the elimination that makes it so, and the MID
section says that having no MID is itself the answer for Grid Islandable rather
than a missing reading. The Grid Islandable table row no longer says "only when
reported", which was true of the gate and not of the entity.

The delta document records both, and the pattern behind them: v1.0 expresses by
*structure* what flat expressed by *property*, and code written against the
property reads the structural form as ignorance. Three instances in two days --
`grid_islandable`, `dominant_power_source`, and the PV entities' area -- so the
question to ask of every remaining `SCHEMA_CONDITIONAL_FIELD` is not "does this
field still resolve" but "is there an answer the absence itself supplies".

Both wrong diagnoses are recorded too, since the second would have quietly
changed flat's degraded behaviour had it shipped.
It was the only panel-level ampere value on the primary card, and the only
member of its own PCS group outside the Diagnostic section -- both of its
siblings were already there.

This reverses a deliberate decision, which had a test and a rationale: "a PCS
throttling import is a fact about the user's electricity supply, not about the
integration's health, so it is not filed as a diagnostic". That reads
`DIAGNOSTIC` as being about the integration. Home Assistant's category is about
the *device* -- "a configuration parameter or diagnostics of a device" -- and by
the earlier standard `l1_voltage` and `main_breaker_rating` are facts about the
user's supply too, and both are diagnostic.

The line this codebase actually draws is not "amps are diagnostic", which would
misfile two entities that are correctly primary: `circuit_current` and
`evse_advertised_current` are amps and are what a circuit or a charger is doing
right now. It is that the panel's own voltages, lug currents, breaker ratings and
limits describe the installation rather than its activity. Import Limit is one of
those -- a ceiling the panel arbitrated, carrying the four limits it was
arbitrated from as attributes.

Presentational only. The entity id, its history and its long-term statistics are
untouched, `state_class` stays, and automations and templates are unaffected;
the category groups it on the device page and keeps it out of auto-generated
dashboards.

Done now because the entity is days old and has appeared in exactly one release's
addition notice, so no installation has a dashboard or history depending on where
it sits. Later would cost something; today costs nothing.
`v2.1.0b5` was tagged at `0442c04`, one commit before the category change, so it
went out without it. The changelog credited the move to b5, which was wrong the
moment that tag was cut -- an entry describing something a release does not
contain is worse than no entry, because it is the release notes that a user
checks when the thing they read about is not there.

Moved to its own `[2.1.0b6]` section. Library pins are unchanged at
`3.0.0b11` / `1.0.0b5` / `0.1.0b8`; nothing in them moved.

Also dropped "has appeared in one release" from the rationale, which stopped
being true when b5 shipped. The argument does not depend on it: the entity is
days old either way, and the same change after general firmware rollout would
cost something.
`construct_voltage_attribute` returned None for a breaker occupying more than
two positions, and every caller turned that into 240 with `or 240` — so the one
case we cannot infer became the one case we asserted most confidently. The
topology service had its own copy of the inference and answered 120 for
everything that was not exactly two-pole.

Callers now omit the attribute when the pole count does not determine the
voltage, and the topology service reads the same helper as the entities.

`construct_tabs_attribute` and the circuit-number entity id builder no longer
branch on one-or-two positions either: they name every position the breaker
occupies. Three positions used to drop the attribute and log that the hardware
was "not valid for US electrical system", which is a poor way to meet input we
have merely not seen. SPAN publishes only 1- and 2-pole breakers, so the wider
form is unreachable on SPAN hardware and exists so unexpected input degrades
into something readable.
The changelog carried five separate beta sections whose combined story was
mostly internal: a retry that was fixed three times, documentation corrected in
both directions, entities promised in b2 and repaired in b5. A reader upgrading
from 2.0.8 sees none of that history — they see one release. So the betas are
collapsed into a single 2.1.0 entry stating final behaviour, keeping the SPAN
firmware transition and the self-reloading upgrade path, which are the reasons
the release exists. Added is one sentence per item rather than a paragraph.

Both documents also framed the schema generation as something a reader chooses
between. The v2 eBus prerequisites were repeated at the top of 2.0.2 through
2.0.6 as live cautions and are now stated once, in past tense, on the 2.0.1
entry that introduced them. Every `v1.0 data model only` and `v2 only` marker is
gone from the README's headings, tables and prose: it is just the firmware the
panel will be running, and the conditions that genuinely vary per install —
"only when BESS commissioned", "only when the feeding circuit reports it" —
were already stated where they matter.
A publisher adding a property to the BESS, a charger, a circuit or the panel
reached the user nowhere: it became a declaration-only discovery row, visible
in a diagnostics attachment and nowhere else. This integration should not be
the gatekeeper on what a publisher can reach a user with, so those properties
now become entities on the device card they belong to -- disabled, diagnostic,
and named in plain wire vocabulary so they read as uncurated, which they are.

**An adopted extension is a terminal identity.** Nothing promotes, re-homes or
migrates one. It changes only on an external trigger: the publisher stops
publishing it, or better metadata arrives. Curation is never blocked by one
existing -- ids are permanent, identity is not.

**Nothing is ever removed.** A row the user deletes returns, disabled, while
the property is still published, so deletion is not suppression and none is
built. Deletion sticks exactly when publishing has stopped, because then
nothing exists to recreate it from.

The unique_id is the one irreversible commitment, so it ships with its tests:

    span_{serial}_adopted_{scope}/{node}/{property}

anchored on the serial and the curated scope, addressed by the wire path
verbatim -- upstream's own capability-catalog spelling. Verbatim is what makes
it injective: `battery-2` + `cell-temperature` and `battery` + `2-cell-
temperature` collapse under any normalising scheme and stay distinct here. Not
the eBus proxy composition, whose ids upstream states are local handles rather
than identities and are unstable across the proxy-to-native transition.

Read-only, no controls: these sit beside curated controls that do real work --
the EVSE limit refuses a value above the commissioned ceiling, the islanding
assertion translates GRID into ON_GRID -- and a generic write path would have
neither. The platform is a one-way door enforced in `resolve_platform`: an
entity domain is part of entity_id and the registry refuses a cross-domain
rename, so whatever domain a row is registered under wins however the
declaration later changes. Metadata may reshape everything else freely, which
is safe precisely because no extension entity carries a `state_class` and so
has no statistics for a unit change to corrupt.

A per-device cap bounds a misbehaving publisher, since rows are permanent and
nothing removes them, and the additions notice collapses them one line per
device -- fifteen new vendor readings must not cost the user the curated
additions in the same message.

Requires span-panel-api 3.0.0b12 and span-panel-api-schema-1 0.1.0b9, which
carry `extension_properties`.
The version bump the extension work needed: b6 ships neither the new entities
nor the library pins they require, so HACS would offer nobody the change.

The changelog entry replaces a bullet rather than adding one. It said a new
property on a device this integration models "stays curated in a release" --
true when it was written, and the precise thing this release changes.
The Adopted Devices section still said devices this integration models are
never adopted, which this release reverses, so that bullet is replaced rather
than joined by a contradicting one.

The new section covers what a vendor reading is, why it arrives switched off
and read-only, why it keeps the panel's plain wording, and the delete
behaviour, which is the part nobody would guess: deleting one hides it until
the next reload while the property is still published and removes it for good
once publishing stops. It also states plainly that these are permanent in id
but not in identity -- a later curated entity is a new entity, which is the
trade for surfacing a reading the moment it appears.
… them

A live b7 install added two vendor readings to the panel -- a postal code and a
time zone, the only two properties that panel declares and this integration
maps nowhere -- and the notification said "Span Panel (2 entities)". That is
strictly less than naming them: the collapse was written for a firmware update
adding fifteen at once, where the flood would cost the reader the curated
additions in the same message, and two is not that.

The distinction the first version missed is which device the line names. An
adopted *device* collapses at any count because its line names a device that
did not exist before, which is itself the news. A vendor reading sits on a card
the user already has, so the card's name says nothing about what appeared on
it. Collapse now applies above five, per notification rather than per device
lifetime: five readings announced last month and one today is a one-line
update, not a flood.
…roblem

Also corrects the README, which described the notification as always giving a
device and a count -- true when written, and the behaviour b8 changes.
**Two lugs devices shared one identity.** The library paired both with the
`panel` subject, so two lugs declaring the same vendor property minted one
unique_id for two readings -- Home Assistant drops the second, and the survivor
shows whichever sorted first. Identical firmware on both lugs makes that the
expected case, not a coincidence. They are `kind="lugs"` keyed by direction
now, and `_SCOPE_PREFIX_BY_KIND` gives them their own scope while
`extension_device_identifier` still renders them on the panel's card: identity
must distinguish what placement merges.

**The cap could strand a user's enabled entity.** It admitted rows in adapter
emission order, which tracks the wire, so a firmware update declaring a
property earlier could evict a standing entity -- whose registry row is
permanent, and for which nothing would build an entity again. Unavailable
forever, stranger in its slot, no migration path by design. `adoptable` now
admits everything the registry already holds before capping the rest.

**The cap counted cards, not devices.** Panel, every circuit and both lugs
render on the panel's card, so thirty-five wire devices shared one allowance:
two vendor properties per circuit truncates a 32-circuit panel with no
misbehaving publisher anywhere. It counts `subject_key` now. The test that
claimed this behaviour only exercised battery-vs-panel and passed while the
code was wrong; it exercises the shared card now.

**Overflow was silent.** `MAX_PER_DEVICE`'s own docstring and the design both
promised a notice; the code logged, twice per setup, once per platform.
`async_notice_declined_extensions` raises a durable notice once at setup,
translated into all five locales.

Also clamps a vendor string to 255 characters rather than letting Home
Assistant raise on the state write.

The baseline reasons for `status/postal-code` and `status/time-zone` said
surfacing them would copy location into recorder history and duplicate what
Home Assistant owns. Both were written when surfacing meant a default-on
curated entity. They now surface as disabled vendor readings, where the
recorder cost is opt-in, so the reasons say what is now true: no curated
entity, which is a different decision from invisibility.

Requires span-panel-api 3.0.0b13 and span-panel-api-schema-1 0.1.0b10.
span-panel-api 3.0.0, span-panel-api-schema-0 1.0.0 and span-panel-api-schema-1
1.0.0 replace the beta pins. Nothing about this integration's behaviour changes:
the libraries are the same code their last betas carried, renumbered for the
first public release.

The pins live in three files and all three move together. pyproject.toml is the
development environment, manifest.json is the list Home Assistant actually reads
at install time, and requirements_test.txt is derived from the manifest -- which
is not a fact worth remembering, because test_the_real_repository_is_in_sync runs
sync-dependencies and fails if any of them disagree. It caught the third file
here after the first two were edited by hand.

manifest.json now names versions that are not on PyPI yet, so the release order
is the libraries first -- v3.0.0, then schema-0-v1.0.0 and schema-1-v1.0.0 --
and this integration afterwards. Until then the editable [tool.uv.sources] paths
supply them, which is what the local lock resolves.

Home Assistant pins are deliberately untouched. homeassistant 2026.8.0,
homeassistant-stubs 2026.8.0 and pytest-homeassistant-custom-component 0.13.354
already agree with each other and with the 2026.8.0 floor hacs.json declares, and
the floor is the version worth testing against: it is the oldest one a user can
be running, while the newest is only where developers drift.
…with

The display names and the entity-id suffixes for circuit energy sensors
disagreed on word order: name="Produced Energy" against a suffix of
energy_produced, and the same for consumed and net. Nothing surfaced it while
this integration assembles entity ids itself, because the two never meet -- the
id comes from the suffix mapping and the name is only a label.

They meet the moment Home Assistant composes the id from the name, which is what
core 2026.8's entity_id_parts does. Composition would slugify "Kitchen Produced
Energy" to kitchen_produced_energy while the entity has shipped as
kitchen_energy_produced since before 2.0.8, and "Recreate entity IDs" would then
offer to reorder the suffix of every circuit energy sensor on the panel -- three
per circuit, 96 on a fully mapped 40-space install. Those offers say nothing
about the circuit names a user set in the SPAN app, which are identical on both
sides; they are purely our own two spellings disagreeing.

So the names move to the ids rather than the other way round. Verified: every
descriptor in CIRCUIT_SENSORS and UNMAPPED_SENSORS now slugifies to exactly the
suffix get_user_friendly_suffix returns for it, through the api_key_mapping in
SpanCircuitEnergySensor that resolves circuit_energy_produced to
producedEnergyWh.

Nothing structural moves. unique_id is built from description_key and never
reads a name, so it is byte-identical and stays single-form and predictable,
which is what a future migration depends on. Entity ids are untouched. What does
change is user-visible: cards read "Kitchen Energy Produced" rather than
"Kitchen Produced Energy". Panel-level descriptors are left alone -- they
already match their own suffixes (main_meter_produced_energy), which are
noun-last, so the two levels necessarily read differently. That asymmetry is
inherited from the ids, not introduced here.

This is a precondition for adopting core's composition rather than a change that
stands on its own; taken alone it is a relabel.
The manifest is the pin that reaches a user; `sync-dependencies` propagated it
to pyproject.toml and requirements_test.txt. The schema adapters stay at 1.0.0 --
only the bootstrap moved.

`uv.lock` is unchanged, and that is not an oversight: `[tool.uv.sources]` points
span-panel-api at the local worktree, and uv lets a path source override the
version specifier rather than checking against it. Local development therefore
resolves to whatever that worktree holds, pin or no pin. CI strips the sources
block and re-locks from PyPI, which is the only place this specifier is enforced.
The library half of this branch merged and released, so the `-p3` worktree these
paths named is gone. A missing path source is not a soft failure -- `uv run`
cannot build the environment, so nothing in the repo runs until it is corrected.

Repointing also lets the 3.0.1 pin resolve: the lock now records span-panel-api
3.0.1 from the primary checkout instead of 3.0.0 from a worktree that no longer
exists. The Pyright extraPath moved with it.

CI never reads this block -- it strips `[tool.uv.sources]` and re-locks from PyPI
-- so a stale path here breaks local work only.
Renaming a circuit in the SPAN app left "Recreate entity IDs" offering the
entity its own ID, so the button appeared to do nothing (#252).

The registry generates an ID from the user's `name` override, else
`suggested_object_id`, else `object_id_base`. Friendly-names mode writes no
registry `name` -- the panel name arrives as `original_name` -- so the
suggestion decides, and `construct_single_circuit_entity_id` was handing back
the entity's stored ID on every reload. The suggestion was therefore frozen at
whatever the circuit was called the day it was added.

It now computes the ID from current panel data and the naming flags, for
existing entities as much as new ones. That cannot move a live entity ID: an
entity_id set before add is only a suggestion, and for a unique_id already on
file `async_get_or_create` routes to `_async_update_entity` with no
`new_entity_id` before `entity.entity_id` is reassigned from the stored entry.
Unique IDs are untouched -- they derive from the description key and never read
a name.

Circuit-numbers mode is deliberately unchanged, guarded by a test. There the
registry `name` phase 2 sync writes is both what shows the panel's name in the
UI and what outranks the suggestion, so Recreate keeps composing from that name.
Correcting it would mean rerouting phase 2 sync, which is a product decision,
not part of this fix.

The registry lookup was the only use of the `unique_id` argument, and
`existing_entity_id` the only use of that parameter on `_construct_entity_id`;
both are gone rather than left as parameters that no longer decide anything.

Every test reloads before asserting -- asserting straight after creation
exercises the first-add path, where the suggestion is trivially current and the
bug cannot appear. The three that demonstrate the bug fail on the previous code;
the five guards pass on both.
Cut from the entity-id branch so the "Recreate entity IDs" fix gets field time
before it merges. Carries span-panel-api 3.0.1 and both adapters at 1.0.0.
Both are outward facing and neither was recorded. Written against 2.1.0, not a
beta -- the changelog describes the public release, and the steps between betas
are not what a reader is looking for.
Recreate offered 74 renames on a real panel after the previous beta: every
circuit energy sensor, `_consumed_energy` to `_energy_consumed`. Only one of the
74 was a circuit the owner had renamed, and that one was buried.

Installs predating the point where the suffix mapping reached entity ids took
their id from the descriptor name, so they carry `_consumed_energy` while their
own unique id carries `_energy_consumed`. The two have disagreed all along;
recomputing the suggestion is what made the disagreement visible.

The descriptor rename is not the cause and reverting it would not have removed a
single offer: in friendly-names mode the registry holds no `name`, so
`suggested_object_id` -- our suffix-based preset -- outranks `original_name` and
the descriptor name never reaches the id. Measured both ways; the proposal is
`_energy_consumed` either way.

So an existing entity keeps the suffix it shipped with and only the circuit-name
half of its id follows the panel. `LEGACY_ENTITY_ID_SUFFIXES` records the older
spellings; comparing with the suffix removed means a renamed circuit still gets
the computed id, which is the whole point of #252. A trailing segment that merely
looks like a suffix change -- "Kitchen Outlets" renamed to "Kitchen" -- is a
rename and is still offered.

`_construct_entity_id` takes `existing_entity_id` again. It no longer decides
whether to compute an id, only which suffix the computed one carries.

Four tests, two of which fail with preservation disabled. The earlier ones all
built their entities with current code, where preset and live id are the same
string by construction and this could not appear -- which is why it reached a
beta.
Recreate in circuit-numbers mode proposed a friendly-name id for a
circuit-numbered entity, so accepting it converted the whole panel to the naming
the mode exists to avoid. Pre-existing -- it shipped in b9 and everything before
-- but only reachable now that Recreate works at all.

`suggested_object_id` was always correct in that mode. It was outranked: phase 2
sync wrote the panel's name into the registry's `name`, and
`_async_get_full_entity_name` reads the suggestion only when `name` is None
(entity_registry.py:513). One field was doing two jobs, and the second was
invisible.

The name now travels as `original_name`, which becomes `object_id_base` and ranks
below the suggestion, so it reaches the UI without touching the id. Clearing it
alone does nothing -- measured, the old sync writes it straight back on the same
load -- so the write had to stop as well.

No migration pass. The six sites that wrote the name already look up their own
entity and already hold the ownership test, so each releases the field as its
entity loads: idempotent, self-healing, and only ever touching a name this
integration wrote. A name the user set fails that test and is left alone, keeping
today's behaviour for those entities.

Both modes now share one naming path -- `_attr_name` from the panel, registry
`name` never written -- leaving the entity_id preset as the only difference
between them, which is the only thing they are supposed to differ in.

Costs, both measured and recorded in the spec: a rename in the SPAN app refreshes
the display at reload rather than in place, and `friendly_name` gains the device
prefix that friendly-names mode already carries. Neither the SPAN card nor a
device page shows the prefix -- both strip it.

Six new tests, four of which fail on the previous code. Eight existing tests
asserted the old scheme and now assert the new one.
adoption.py claimed "the unit of adoption is a device, never a property"
and argued against ever minting an entity for a property on a modelled
device -- which extension.py has done since it shipped. It now states the
split between the two modules and why a settable property becomes a
control on an adopted device and a reading on a curated one.

The five sensors disabled for SPAN API defects repeated the same
eight-line note at each site. One paragraph in the module docstring names
all five; each site keeps a one-line pointer to #234, which is now the
pinned tracking issue for them.

CHANGELOG, 2.1.0 only:

- Every entry trimmed to at most two sentences, and the four entries
  covering "Recreate entity IDs" collapsed into one line stating the
  fixed behaviour rather than the sequence of beta fixes.
- Three entries corrected against the code: circuit and lugs vendor
  readings land on the panel's card rather than a card of their own; the
  new-entity notification collapses an adopted device, and any card
  gaining more than five vendor readings, into a line with a count; and
  the controls-vs-readings bullets now name their subject, since adopted
  devices do get switches and selects while extensions never do.
- Dropped the Grid Forming Entity entry: resolve_dominant_power_source
  preserves the value space deliberately, so nothing observable changed.
- Spelling and grammar.
…dated files

hassfest validates strings.json and translations/en.json against Home
Assistant's own schema and rejects any key it does not define, so the
`notifications` section those files carried failed the check outright --
"extra keys not allowed @ data['notifications']". That is what an
unsupported custom category looks like from the outside, and it is the
same fact notices.py already documents as its reason for reading these
strings itself rather than through homeassistant.helpers.translation.

The strings move to a notifications/ directory beside translations/, one
file per language, so hassfest sees only the categories it defines and
the notices keep their per-language files. read_translations reads the
new directory; its language-chain fallback is unchanged.

sync_translations.py validates the new directory the way it validates
translations/, against notifications/en.json instead of strings.json --
without that these five files would have silently lost the key-parity
guarantee the move was supposed to preserve. Verified by removing a key
from the French file and watching the hook fail.

1269 tests passing; full prek set green.
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