Skip to content

Merge from upstream - #2

Merged
gdgib merged 386 commits into
haext:mainfrom
SpanPanel:main
Oct 8, 2026
Merged

gdgib merged 386 commits into
haext:mainfrom
SpanPanel:main

Conversation

@gdgib

@gdgib gdgib commented Oct 8, 2026

Copy link
Copy Markdown

No description provided.

…ktree

`extraPaths` named `span-panel-api-security`, a scratch worktree created for the
security work and merged since. Two consequences, one of which was visible all
along: Pyright resolved `span_panel_api` through both that path and the
installed distribution, so every symbol crossing the boundary reported as two
incompatible types with the same name -- `models.V2HomieSchema` is not
assignable to `span_panel_api.models.V2HomieSchema`. mypy, which reads the
installed distribution alone, was clean throughout.

The second is that the path was about to break: the worktree is being removed
now that its branch is merged.

`[tool.uv.sources]` above already names the real checkout; this line was the
one left behind.
span-panel-api now reports a meter value the panel has not sent as None
rather than fabricating 0.0, because on a cumulative counter the two are
not interchangeable — a fabricated zero is indistinguishable from the
reset the dip compensator exists to absorb (#259).

The four derived value functions flattened that distinction straight back
out. Net energy, at circuit and panel level, was `(a or 0) - (b or 0)`, so
a circuit that had reported nothing answered a confident 0 Wh; those are
TOTAL, not TOTAL_INCREASING, so the cost is a false dip to zero in history
rather than corrupted statistics, but it is the same invented reading one
layer further out and past the point where anything downstream can tell.

Circuit power failed the other way: `-c.instant_power_w` raises TypeError
on None, caught upstream and logged as a value-function failure for every
PV circuit on every replay.

Both now compose through helpers that propagate None, which reaches
_process_raw_value and renders the sensor unknown. The panel pair were the
worst exposed: both lugs devices are told apart by one `direction`
property, so until it arrives neither role resolves and all four inputs
are absent together.

Test factories widen to float | None to match the snapshot model.
Dip compensation booked an offset the moment a counter read lower than it
had, on the evidence of one sample, and that write is irreversible: it
lands in long-term statistics, where undoing it is a manual adjustment per
affected hour. #259 is what a wrong sample costs there — a
transient zero booking a circuit's whole lifetime counter, every restart,
reaching 8.18 MWh on one circuit.

A firmware counter reset is permanent: the counter restarts low and counts
up from there. So a reading that drops and then returns was never a reset.
Compensation is still applied the moment the drop is seen, because the
sensor has to stay correct meanwhile, but the conclusion is held open:

  retract   raw >= baseline - 1.0            the counter came back
  confirm   dipped_raw < raw < baseline-1.0  it is counting from the lower base
  hold      raw == dipped_raw                no evidence either way

Confirmation needs independent progress rather than a second low reading.
Snapshots dispatch on a debounce, so one replay can deliver the same
incomplete state twice; a rule satisfied by any low reading would be
satisfied by a second copy of the sample it exists to reject.

The notification moves from detection to confirmation. Firing on sight is
what made #259 so loud — a notification naming essentially every circuit
on the panel for an event that had not happened. A retracted dip now
produces none, because nothing happened.

The pending record persists, because a restart is exactly when one is most
likely to be outstanding: the reading that settles a dip is the one after
the reading that booked it, and HA can stop in between. Forgetting it was
provisional would make the restart itself the thing that makes a bad
offset permanent.

Two existing tests pinned the old behaviour and now state the new: a
counter that came back used to leave its offset behind for good, and the
coordinator was told on detection.

Cost: a genuine reset on a circuit drawing nothing stays unconfirmed until
it next moves. Only the notification waits; compensation applies
throughout.
… time

`3cbf02a` pointed `[tool.uv.sources]` and `[tool.pyright].extraPaths` at a
scratch worktree and committed it. It stood until `82a512f` -- several commits
and hours of other work -- and while it stood a test fixture was vendored
through that resolution from a checkout that still recorded a producer defect
the library had already fixed. Every version-based check passed, because the
stale worktree declared the same version number as the corrected code. Only a
byte comparison caught it.

A version string does not identify content. Only a filesystem location does.

Three layers, so the defect cannot repeat.

`tests/test_library_resolution.py` reads `span_panel_api.__file__` and compares
it against what `pyproject.toml` declares and what `manifest.json` pins --
neither written down here, both derived. The pinned version out of the declared
location passes. That location at another version fails everywhere: nothing
else can see it, because every copy of the pin agrees with the checkout's own
declaration. A deliberate editable override skips locally, naming the path and
version it found, and fails under `CI`, where the workflow deletes
`[tool.uv.sources]` and no legitimate override exists.

The fourth case is the dangerous one. A worktree on `PYTHONPATH` or `MYPYPATH`
has no distribution metadata, so `importlib.metadata` keeps describing the
installed distribution -- right name, right version, right location -- while
the code that runs comes from somewhere else. Every version check passes on
facts about a copy that is not running. Classified before the version is
compared, because in that state the comparison is not about the code under
test.

`developer.md` documents the alternative to editing a committed file: install
the worktree into the venv, `uv sync` to undo it. The root cause was that
working against unreleased library code required editing a shared file; it does
not any more.

`scripts/check-library-path.py` rejects a `pyproject.toml` whose span-panel-api
paths name anything but `../../span/span-panel-api`, and says how to redirect
the library without touching the file. Both blocks, not just the sources one:
they went wrong together and were corrected separately, `extraPaths` lagging by
several commits because nothing was watching it. An absent block passes -- CI
deletes it before installing and re-runs these hooks against what is left.

The skip-locally, fail-in-CI asymmetry and its reasoning are
`test_fixture_provenance.py`'s: a skip reads in a summary line exactly like a
pass.
The hook's failing cases were miniature pyproject.toml files written here. A
synthetic defect is written by somebody who already knows what the rule checks,
so it exercises the rule rather than the mistake -- a gate proven against a
made-up example is proven against the wrong thing.

Both failing cases are now byte copies of this repository's own pyproject.toml,
taken out of its history and held to it:

  pyproject_both_blocks_redirected.toml        3cbf02a, both blocks on the
                                               scratch worktree
  pyproject_type_checker_path_left_behind.toml 82a512f^, sources corrected and
                                               extraPaths not

The second is the interval between the two corrections, several commits during
which the repository looked fixed. It is the case a hook covering only
`[tool.uv.sources]` calls clean, and it asserts that the corrected half is *not*
reported -- so what fails there is the half that was genuinely still broken.

Each copy has a `.source` recording its commit, and a guard holding the bytes to
it, or the obvious way to make a failing case pass is to edit the fixture. That
guard skips when the object is unreachable, which this directory's other
provenance guard refuses to do. The difference is that a commit is
content-addressed: `3cbf02a:pyproject.toml` cannot become different bytes, so
the comparison can only ever report a copy that was wrong when it was vendored,
and it reports that wherever the history exists. A shallow clone has no object
to compare against, and asserting anyway would fail on the clone rather than on
the fixture.

Every case drives `scripts/check-library-path.py` as a subprocess, the way prek
invokes it -- never the module's functions, never the rule restated in the test.
`test_the_hook_prek_runs_is_the_one_tested` closes what that leaves open by
reading the entry back out of `prek.toml`: rename the script or the hook and
every other case here goes on passing against something prek no longer runs, and
an unregistered hook produces no output either. It reads the file as text
because `prek.toml` spreads each hook's inline table over several lines, which
is TOML 1.1 and `tomllib` rejects.

The two vendored copies join the adapter captures in being exempt from the
rewriting hooks, for the reason already recorded there: a formatter and a
byte-identical fixture cannot both be unconditional, and it is the formatter
that yields.
Grace period and dip compensation share a stored record but not a
decision, and nothing pinned the boundary. While the panel is offline
`_update_native_value` hands off to the grace period and returns before any
dip processing, so an outage cannot book, confirm or retract anything.

That division is deliberate: grace period answers what a sensor should read
while the panel is unreachable, dip compensation answers what the counter
did, and an outage is evidence about the transport rather than about the
counter. The alternative matters — an outage landing between a dip and the
reading that would settle it could otherwise confirm a bad offset by
default, which is the same shape as forgetting the pending record across a
restart.

Covers the three parts: the pending record and offset survive the outage
untouched, grace holds the compensated value HA was already showing, and
the dip settles normally once the panel returns.
…ffset

Two corrections to the README, both consequences of the retraction rule.

The feature section said a persistent notification lists affected sensors
"when a dip is detected". That is no longer true and teaches the wrong
model: a dip is compensated on sight but not believed, so the notification
fires on corroboration a reading later, and a disproved dip produces none
at all. Someone watching a momentary dip pass with no notification would
reasonably conclude the feature had failed, when that silence is it
working.

The troubleshooting row offered dip compensation as the prevention for
energy dashboard spikes, which after #259 is half the story -- compensation
could manufacture them. It now also says how to check: an `energy_offset`
far larger than the sensor's own reading is compensation for a dip that
never happened, and toggling the option off discards it.

It says plainly what that cannot do. Offsets booked before 2.1.0 were never
corroborated, so the integration cannot tell a good one from a bad one
retrospectively -- which is why there is no repair flow offering to. And
clearing an offset fixes the future only; spikes already written into
long-term statistics still need Adjust sum.

Table rows reflow because the column padding is recomputed; the text change
is confined to the one cell. Both edits are `scripts/fix-markdown.sh`
output.
…ps creating

`disabled` had to be a mode that stops *creating* controls rather than one that
removes them, because entity_id is permanent here. Nothing asserted that. The
existing test checks only that no entities are added, which both spellings
satisfy -- a mode that removed every registry entry on the way past would have
passed it unchanged.

Its docstring claimed the other half anyway: "No control entities, and no
registry entries removed either." A claim made in a docstring and checked
nowhere is worse than an absent test, because it reads as coverage to anyone
auditing by name -- which is how this gap was found. That sentence now says
what the test actually proves and points at the one below.

The new test registers an entry, customises its name and icon, runs setup under
`disabled`, and asserts the entry survives with its entity_id, its unique_id
mapping and its customisations intact. Customised rather than bare because the
cost of getting this wrong is not the identifier alone: a user's name, area and
icon travel with the registry entry and do not come back with a reissued id.

Verified by injecting the defect -- removing the switch entries on the disabled
path -- and confirming this fails, then restoring. A guard that has never been
seen to fire is not known to be a guard.
The first beta whose pins resolve. b14 named span-panel-api 3.1.0 and both
adapters at 1.1.0 while none of the three existed on PyPI, so the pins were
honest about what the code needed and unsatisfiable by anyone but a developer
with local path overrides -- deliberately, since the alternative was pins that
resolved to a library without the changes. All three published today, so CI can
now install what the manifest asks for.

What the beta carries beyond b14:

- The panel credential no longer travels or rests in clear. The passphrase is
  not persisted, and the CA is fetched and confirmed before registration rather
  than after, so the exchange that carries the passphrase and returns both
  credentials is the first thing protected rather than the last.
- The panel's CA is pinned and will not silently re-anchor. A change raises a
  repair naming both fingerprints and re-pins only on an explicit confirmation.
- Control commands are gated on who is asking, and every one is recorded --
  refusals included, which are the entries worth having.
- No control silently swallows a command that did not happen. A refused command
  raises where the user made it; so does one the transport never handed over.
- An absent meter reading is no longer read as zero, and a compensation offset
  the next reading disproves is retracted rather than kept.

The vendored schema-1 capture is verified against the released
schema-1-v1.1.0 rather than against unreleased bytes, which is the first time
that check has had a release to verify against.
Adds the energy dip fix (#259) and cuts every entry under Changed, Fixed
and Security to a single sentence, without leaning on a dash to bolt a
second clause onto the first. Also drops the paragraph on SPAN's upgrade
timing from the release preamble, which restated what the section above it
already says.

What came out was mechanism and caveat: how the dip was mistaken for a
counter reset, what happens to offsets stored by earlier releases, why a
changed CA cannot be re-pinned automatically, what a refused control used
to do instead. All of it is in the README's Security section and
troubleshooting table, which is where someone chasing one of these looks.

What was kept is what an upgrader cannot find out any other way: which
entities are affected, that ids and history survive, and where a template
reading a moved attribute should now point.
Setup showed a step headed "Trust this panel's certificate": a 64-character
fingerprint, four paragraphs on what pinning does and does not protect against,
and one button. It asked the user to make a decision they cannot make.

At first contact there is nothing to compare the fingerprint against. SPAN
publishes it nowhere, so the screen said "comparing this fingerprint against
another source is what closes that case" and then offered no source. It also
told them a device standing between Home Assistant and the panel "would still
see them" -- accurate, unactionable, and delivered at the moment they are least
equipped to weigh it. The only available answer was Submit, which makes the
question decoration.

The protection was never in the asking. It is in pinning *before* registration,
so the exchange that carries the passphrase and returns both credentials runs
over a verified connection. That is unchanged: the CA is fetched, the panel's
own certificate is checked against it, and it is pinned, before authentication
is offered. One less step, and the same guarantee.

The fingerprint moves to where it can be acted on rather than acknowledged:
diagnostics report it under `panel_ca`, setup logs it at INFO so the value is
recoverable from the install that pinned it, and the Repair raised if it ever
changes carries both the pinned and the observed value. That last one is the
moment the question becomes answerable, because there is a prior value to
compare against and something has actually happened.

Kept: the error step. A CA that cannot be fetched, or a certificate the panel
serves that its own authority did not sign, still stops setup and offers a
retry -- that failure has a remedy, so it is worth a screen.

`test_the_ca_is_pinned_before_the_passphrase_is_ever_sent` now asserts the
property rather than the screen that used to carry it: when the auth menu is
offered, the panel has been asked nothing yet, and when registration does run
it carries a transport with an SSL context on port 443.
The HTTPS-port question exists for an install reaching the panel through
something that moved the TLS port, and it is the right question when nobody
else can answer it. A panel discovered from the simulator add-on is not that
install: the add-on allocates a TLS port per panel and reallocates it across
restarts, so it is the only party that knows, and it now publishes what it
knows -- `https_port` in the Supervisor record, `httpsPort` in the mDNS TXT.

Read both, and skip the prompt when either spoke. Asking after being told is
asking a user to confirm a fact they have no way to check, with the wrong
answer costing them a `ca_leaf_mismatch` and no way past it.

Nothing moves for hardware. Real firmware publishes no TLS port, so the flag
stays false and the gate is what it was: no prompt on port 80, a prompt on a
moved one. A panel behind a reverse proxy advertises `httpPort` and nothing
else, and is still asked -- reading a port from discovery must not become a
way for that install to silently check the CA against 443. An unreadable
value is treated as unpublished for the same reason.

The ports also join the discovery update on an already-configured entry,
since an entry left pointing at last run's ports cannot reach its panel.

The inline TXT parse became `_discovered_port`, which both discovery steps
now share; it keeps the old fallbacks exactly, including 80 for a value that
will not parse.
Carries the discovery change: a panel that publishes its TLS port is taken
at its word, so setting up a simulated panel no longer stops on a question
only the add-on could answer. Needs PanelBench 2.2.0 on the other side to
have a TLS port worth publishing.
The integration is moving off presetting entity ids onto supplying one base
per circuit entity and letting Home Assistant compose the rest from the
user's entity_id_parts. The base reads an existing entity's suffix wording
back from its own id, so Recreate proposes it its own id; a new entity gets
the noun-last wording the panel level already uses.
…r wrote

Three platforms each rebuilt the expected string by hand against only the
description's current label, so a relabel silently stopped the release for
that platform. One helper takes every label the description has carried.
Core reads suggested_object_id into object_id_base and composes the rest
from the user's entity_id_parts. Entities that set no base are unchanged.
The integration no longer presets a circuit sensor's entity_id. It hands
Core one flag-correct base and Core assembles the id from the user's
entity_id_parts, exactly as every non-circuit entity already does. An
existing entity keeps the suffix wording its id already has, so Recreate
proposes it its own id; a new one gets the noun-last wording the panel
level uses. The energy labels return to that wording too, and a name 2.0.8
wrote under either label is handed back.
…d otherwise move

Handing Home Assistant the job of composing a circuit entity id changes how
two shapes are spelled, for reasons belonging to this integration rather
than to the user: a sensor shown on a sub-device card, whose id names the
panel where composition names the charger, and any circuit entity on an
install with the device prefix off, which has_entity_name prefixes anyway.
Recreate would have offered both moves, and R1 says it must only ever offer
one the user caused. Those entities go on being preset, from current panel
data so a renamed circuit still refreshes the name half; everything new
composes. The registry name release now reads its legacy labels through a
typed narrow rather than getattr, and the two object-id hooks are one.
Same shape as the circuit sensors: one base, Core composes, the name an
older release wrote is released through the shared helper.

The breaker switch and the priority select were the last two circuit
platforms spelling their own entity ids. Both now hand Home Assistant an
object-id base -- the naming-flag half plus a suffix each names outright,
"breaker" and "circuit_priority", since `get_user_friendly_suffix` has no
entry for the one and maps the other to the unique_id's spelling.

With the id no longer composed from the display name, the name is free to
be the panel's in both naming modes rather than the mode's on a first
install and the panel's afterwards. It travels as `original_name`, which
ranks below the base, so what is shown cannot decide what "Recreate
entity IDs" proposes -- and an unnamed circuit is named for its tabs
instead of being left to HA, which gave every one of them the same id
stem.

R1's exception is now asked once. The two shapes composition spells
differently -- a sensor on a sub-device card, and any circuit entity on
an install with the device prefix off -- were a sensor-only decision;
three platforms facing the same question with three copies of the answer
is how they drift apart on which entities a release moves. The decision
moves to `naming.legacy_preset_for_existing` and the sensors ask it too,
keeping only the fact a sensor class alone knows: whether this
integration ever preset that entity's id at all.
Four platforms now take the same route into the entity registry, and the
helper that drove them had four copies of it: each of the switch, select
and legacy-energy installs carried its own `load` differing from the
sensor's only in the domain string and the constructor call, plus a
`type: ignore[override]` to excuse a return type the base had fixed to
one platform's entity.

`_Install` becomes generic over the entity it installs and owns the one
`load`. A subclass declares a `_domain` and a `_build`, so a change to the
path under test -- the real `EntityPlatform` add, the `async_reset`
teardown -- reaches all four at once instead of three of them drifting.
The legacy install keeps its registry seeding as its own hook, since
several of its cases exist to test the unseeded path.

No assertion changed. The power-sensor install is now `_SensorInstall`,
the base name having gone to the generic; its call sites moved with it.
construct_single_circuit_entity_id and preserve_legacy_entity_id_suffix are
replaced by naming.circuit_object_id_base; the multi-circuit and unmapped
builders had no callers.

Three things fell out with them. _generate_friendly_name was dead across the
integration -- fourteen implementations existed only to satisfy an abstract
declaration nothing called, since _attr_name has always come from
_generate_panel_name. The `name` constructor parameter on the breaker switch
and the priority select had already been documented as deliberately unused,
so it goes rather than stay as a trap. And sensor_circuit's _LOGGER had no
call sites left.

LEGACY_ENTITY_ID_SUFFIXES is superseded by naming.ENTITY_ID_SUFFIX_FORMS,
which records every wording an id has shipped with rather than only the
pre-preset ones; the paragraph of the three suffix-mapping docstrings that
pointed at it now points at naming.py. CIRCUIT_SUFFIX_MAPPING itself is
untouched -- it builds unique ids and is closed.
…el carries

Home Assistant slugifies "{device} {base}" as one string; the deleted preset
builder slugified the circuit name alone and joined the parts with `_`. Pins
that the two agree for the names a panel can actually carry -- punctuation,
runs of whitespace, accents -- so no new install diverges from today's shape.

One case disagreed, and the old builder won. Its suffix-omission test was
`circuit_part.endswith(f"_{suffix}")`, with a leading underscore, so a circuit
named exactly "Power" kept both halves and got `..._power_power`.
`circuit_object_id_base` tested without the underscore and would have offered
every such circuit a rename to `..._power` -- a move the user did not cause,
which R1 forbids. The rule now carries the underscore, and "Solar Power" still
omits the word as it always did.

Also drops an unused logger from sensor_panel.py and corrects a test docstring
that claimed every panel sensor takes its displayed name from
`_generate_panel_name`; the constructor does not ask it for a description that
declares a translation_key, whose label comes from translations.
…h the reload it requests

Pins phase 2 name sync end to end for sensor, switch and select in both
naming modes, through a real EntityPlatform: the rename asks for a reload,
the reload rebuilds original_name, the entity id does not move, and a name
the user set is left alone.

The README promises this in both modes and the composition change moved where
the name is written, so the promise needed a case that can see the registry.
The per-platform unit tests drive the same code against a MagicMock registry,
which cannot tell `original_name` from `name` -- and writing `name` is exactly
the shortcut that made Recreate propose a friendly-name id for a
circuit-numbered entity.

Each case settles the install with a reload and an unchanged push before
renaming, so the reload it then asks for is the one the rename caused and not
the one every freshly-added entity asks for on its first update.
The circuit sensors, the breaker switch and the priority select no longer
preset an entity_id; each supplies a base and Home Assistant composes the
rest from entity_id_parts. Record what an upgrading user sees: nothing
moves until Recreate is pressed, what Recreate then offers, the noun-last
energy suffixes for new sensors, the two populations that keep a preset,
the tab fallback for an unnamed circuit, and the device prefix that now
appears in a circuit-numbers install's displayed name.

Drop the 2.1.0 bullet announcing the energy-label rename: the labels are
back to what 2.0.8 shipped, so only the betas ever saw the other wording.

developer.md gains the mechanism -- naming.py as the only place circuit id
wording is written, the suggested_object_id override, the legacy preset and
why R1 requires it, and the release of the registry name field.

The Recreate test whose docstring claims both modes display the new name
now asserts it.
The Changed bullet claimed the integration never writes or clears the
registry name, which the Fixed bullet two lines down contradicts: the
release does clear the value 2.0.8 wrote. Say the narrower true thing.

Also: the displayed-name example uses the default device name Span Panel;
the EVSE feed-circuit wording is "sensors created from this release on",
since a charger added later to an existing install composes too; and the
entity-id settings are named by their key rather than by a menu path
nobody has verified against the 2026.8 frontend.

developer.md gains the fact most likely to be "simplified" away later --
Core files suggested_object_id as object_id_base, which the user's parts
prefix, while a preset entity_id becomes a verbatim unprefixed suggestion
that outranks it -- plus the third gate keeping the unmapped-tab sensors
off the preset path, and the platform count corrected to three.
…ack anchored on the circuit

Two ways "Recreate entity IDs" still offered a move nobody asked for (R1).

The config flow names the panel device: the second panel on a system becomes
"Span Panel 2" without anyone typing it. Composition spells the device half
from that name, while every circuit id already on that panel says
`span_panel_` -- the literal the deleted preset builder fell back to, since it
was never told the panel's name. So the whole panel was offered
`sensor.span_panel_2_...`. The preset exception now covers a generated device
name that is not "Span Panel", read from the registry's `name`. A name in
`name_by_user` is the user's and still composes, because Home Assistant
composes from `name_by_user or name` and following that is following the user.

The suffix half was read back from the end of the existing id and the omission
rule decided from the identifier alone, so the two could disagree with the id
they were meant to reproduce. A circuit named "Current" had
`..._current_power` read as the `current_power` form and was offered
`..._current_current_power`; a circuit named "Solar Power" whose id kept both
halves was offered `..._solar_power`. Both questions are one question, so
where the id still names the circuit they are answered together, anchored on
the identifier: what follows the name is the form, and a name the id stops at
means the form was omitted. A circuit renamed on the panel is unchanged --
its id no longer names it, so the suffix is read from the end as before and
the name half follows the panel, which is issue #252.
…sent

Reauth builds its REST transport from the entry's stored data, and an entry
with no usable pin got a plaintext one. Registration is the exchange that
carries the panel passphrase and returns the eBus broker password, so on
those entries the credentials crossed the network in the clear -- the exact
thing pinning before registration exists to remove, and the opposite of what
the release notes tell upgrading users.

Three populations reach reauth unpinned: entries still recorded as
api_version "v1", which fail setup before the deferred fetch can run and
which the v7 migration does not flag; v2 entries missing their broker
credentials, which fail setup the same way; and entries whose deferred fetch
failed. A fourth, an entry whose stored PEM no longer loads, falls back to
plaintext in panel_rest_transport and is now re-pinned as well.

Reauth of such an entry now routes through the CA step -- fetch, leaf check,
adopt -- and only then shows the reauth menu, so both authentication methods
register over the anchor. A leaf that does not chain shows ca_leaf_mismatch
with no way past, as on install. _update_v2_entry keeps the acquired CA and
clears the pending flag, so the entry stays pinned instead of falling back to
a plaintext CA fetch on every connect. An entry that already carried a usable
pin is untouched and skips the step.

The entry's stored HTTPS port is adopted on reauth so a panel behind a proxy
is not asked again for a port it already recorded, and is persisted alongside
the CA when the user does have to name it.
… actually names

A panel's certificate names the addresses it already knows itself by -- its IP
and its .local name. An FQDN joins that list only when register_fqdn tells the
panel to regenerate the leaf, and that runs after authentication. Every check
before it was made against the FQDN with hostname verification on, so it failed
on the one thing that could not yet be true.

Two flows were unusable as a result. A fresh install named by FQDN died at the
CA step with ca_leaf_mismatch, which is deliberately a dead end with no way
past, so the install could not complete at all. Reconfiguring a pinned entry to
an FQDN died one step earlier: detect_api_version dialled the new name under the
stored anchor and reported cannot_connect. Neither was caught because every FQDN
test in the suite ran under an autouse fixture that stubs async_leaf_chains_to_ca
and build_panel_ssl_context, so hostname verification was never exercised.

The pre-registration bootstrap now dials the panel's resolved address: the CA
fetch, the leaf check, registration, and the FQDN request itself. Hostname
verification is not relaxed anywhere -- the address checked against changed, the
check did not, and a leaf the published CA does not sign is refused exactly as
before. A name that will not resolve falls back to dialling the name, which is
what shipped. Only once the panel reports the regenerated certificate is the
FQDN verified, on the broker port and again on the port the entry will store,
before anything is persisted.

check_fqdn_tls_ready took the anchor it verifies against by refetching the CA
over plain HTTP every two seconds, which is a fresh trust decision on each poll
against whatever answered -- and anything that can answer it can also serve a
leaf signed by the CA it just handed over. It now takes the pinned PEM, carried
on PanelRestTransport beside the context built from it. The plaintext fetch
survives only for a reconfigure of an entry that never acquired an anchor.

Folded in, from the review of the reauth pinning that preceded this:
validate_v2_passphrase and validate_v2_proximity now require a transport instead
of defaulting to a plaintext one, so the calls that carry the passphrase and
return the broker password cannot be made unpinned by omission; the proximity
pre-check, which decides whether the door challenge is believed, runs over the
pin rather than an unverified client; a reauth that replaces a stored PEM this
system can no longer load logs the new fingerprint at WARNING; and a stored
HTTPS port counts as known only when it could actually be read, rather than when
a default was substituted for it.
R1 was clarified on 2026-08-26 (evening) in the circuit-numbers spec:

  The base must reproduce an existing entity's id *when Home Assistant
  composes it under the entity-id options that match how that install was
  built*. That is what "never offer a rename the user did not cause" means:
  the base carries the install's own suffix wording (`consumed_energy` or
  `energy_consumed`, read back from the id) and the circuit's own name, so
  two spellings of ours or a relabel never produce an offer. It does **not**
  mean bypassing Home Assistant's `entity_id_parts` with a hard-coded id:
  where composition yields a different device part (an EVSE feed sensor on
  the charger's card, a second panel named "Span Panel 2", an install with
  the legacy no-prefix option), that is Home Assistant's configuration at
  work and the offer is legitimate. The "legacy preset" exceptions added
  during implementation on 2026-08-26 are therefore withdrawn. Nothing moves
  an id without the user pressing Recreate.

Withdrawn accordingly:

- naming.py: LEGACY_PRESET_DEVICE_NAME, generated_panel_device_name,
  legacy_preset_entity_id, _names_another_device, legacy_preset_for_existing,
  and the now-unused dr / HomeAssistant / DOMAIN / Final imports. The module
  docstring states the clarified rule.
- entity.py: _generated_panel_device_name.
- sensor_base.py: _preset_composition_would_move,
  _id_was_preset_by_this_integration, the `self.entity_id = preset` branch and
  the USE_DEVICE_PREFIX import. The base is set whenever _object_id_parts
  returns parts and the entity is not a sub-device.
- sensor_circuit.py: both _id_was_preset_by_this_integration class attributes.
- switch.py, select.py: the legacy_preset_for_existing call, the preset branch
  and the USE_DEVICE_PREFIX read. _span_object_id_base is always set.

No `self.entity_id = ...` remains in the four circuit platforms; Home
Assistant composes every id from the user's entity_id_parts.

Kept, because they are base fidelity rather than a bypass: the per-install
suffix read-back, the identifier-anchored form test, and the sub-device
sensors that supply no base and compose from their label on the charger.

Tests: the "offered its own id" cases for a sub-device sensor, a no-prefix
install and a generated "Span Panel 2" panel now assert what Home Assistant
composes under (DEVICE, ENTITY) parts -- sensor.span_panel_ev_charger_power,
sensor.span_panel_refrigerator_power, sensor.span_panel_2_refrigerator_power
and the two control equivalents -- and that the live entity_id is unchanged
after two reloads (R5). The unit tests of the deleted symbols are gone; every
suffix read-back, rename and circuit_object_id_base case is unchanged.

README, developer.md and the 2.1.0 changelog describe the base and the
read-back only, and say that these three populations may be offered the
composed id, their entity-id settings deciding, with nothing moving until
they accept.
The EVSE naming section still described the withdrawn exemption -- "sensors
that already exist keep the IDs they have, which name the panel". Nothing
keeps an id any more: a feed sensor's entity id names the charger, and an
existing one is offered that id when the user presses Recreate.

developer.md now states plainly that the suffix read-back does not reach
sub-device sensors. It lives in circuit_object_id_base, which a sub-device
sensor never calls -- with no base, Core composes the entity half from the
description label. An existing feed energy sensor spelled `..._energy_consumed`
is therefore offered `sensor.<charger>_consumed_energy`, both halves changing,
and that is by design: reading a base back for these is the bypass the
clarified R1 forbids.

README also carried the R5 sentence twice over two paragraphs; it now appears
once, in the paragraph that describes the offer.
fix: Net Energy under dip compensation and withdrawn circuit controls (2.1.2b6)
fix: apply dip compensation changes made during start-up, keep its default off (2.1.2b7)
docs(changelog): consolidate 2.1.2 into user-facing grouped entries
docs(changelog): say to install 2.1.2 before firmware r202639
@gdgib gdgib self-assigned this Oct 8, 2026
@gdgib
gdgib merged commit e3876d1 into haext:main Oct 8, 2026
7 checks passed
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.

3 participants