release: 2.1.0 — SPAN firmware r202633 support, new devices, naming fixes - #257
Open
cayossarian wants to merge 119 commits into
Open
release: 2.1.0 — SPAN firmware r202633 support, new devices, naming fixes#257cayossarian wants to merge 119 commits into
cayossarian wants to merge 119 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release branch for 2.1.0, currently at
2.1.0b14. 118 commits, 100 files, +22,219 / −1,832 againstmain.This collapses a three-deep branch chain into one PR.
fix/circuit-numbers-recreatesits onfix/recreate-entity-ids, which sits onfeat/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 thanmain.mainholds 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 onr202633. 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.mdunder 2.1.0.Verification
prekset green: ruff, mypy strict, vulture, bandit, radon, markdownlint, translation sync, dependency-pin sync.span-panel-api==3.0.1with schemas at1.0.0, both released.v2.1.0b14is published for testing and carries the 2.1.0 changelog as its notes.Before merging
Two Dependabot PRs (#251, #248) target
mainand will need a rebase after this lands; #248 touchespyproject.toml, which this branch also changes.