Skip to content

release: span-panel-api 3.0.0 with schema-0 and schema-1 adapters at 1.0.0 - #157

Merged
cayossarian merged 123 commits into
mainfrom
feat/discovery-and-catalog-validation
Aug 23, 2026
Merged

release: span-panel-api 3.0.0 with schema-0 and schema-1 adapters at 1.0.0#157
cayossarian merged 123 commits into
mainfrom
feat/discovery-and-catalog-validation

Conversation

@cayossarian

Copy link
Copy Markdown
Member

Brings the adapter split to main so the three distributions can be tagged and published, ready for the integration to pin them.

What this is

span-panel-api becomes a transport and dispatcher containing no parser. Wire formats ship as separate distributions and register through the span_panel_api.schema_adapters entry-point group, so supporting a new panel schema is an install rather than an upgrade.

distribution version
span-panel-api 3.0.0
span-panel-api-schema-0 1.0.0 (flat, firmware r202603-r202627)
span-panel-api-schema-1 1.0.0 (parent/child, r202633+)

Breaking

  • No parser in span-panel-api. Installing it alone connects and then raises SpanPanelAdapterMissingError. Use pip install "span-panel-api[schema-0]" / [schema-1].
  • HomieLifecycle, HomiePropertyAccumulator, HomieDeviceConsumer unexported — they are flat-schema-specific and now live in span_panel_api_schema_0.
  • product_name retired on battery, evse and pv in favour of model / part_number. battery.model changes value for existing flat users.
  • Python floor is 3.14, matching the only consumer: Home Assistant requires >=3.14.2 from 2026.3, and the SPAN integration requires HA 2026.8+. Floor and CI matrix are the same version, so a green run proves the declared range.

Verification

All 20 pre-commit hooks; 923 passed / 7 skipped; coverage 95.15% against an 85 gate; all six artifacts build; twine check passes on all six; every wheel ships py.typed and declares Requires-Python: <4.0,>=3.14; the adapter-less install fails by name on a clean 3.14 venv.

Dependency floors name stable versions rather than the prereleases they tracked during development — a specifier naming a prerelease is pip's own signal that prereleases are acceptable for that requirement. ebus-sdk is exercised at 0.23.1, so the declared <0.24 ceiling is a tested claim.

Release order

Tag bootstrap first — v3.0.0, then schema-0-v1.0.0 and schema-1-v1.0.0 — so there is never a window where an adapter is installable and its dependency is not.

Note

PR #148 (strict-x509 + CA 429 retry) targets main and is not in this branch. The TLS half does not affect 3.x — _build_ssl_context builds a fresh SSLContext rather than using create_default_context(), and VERIFY_X509_STRICT is not set on it at 3.14 (verified). The 429 retry half does apply: auth.py raises on any non-200 from the CA endpoint with no retry, so that fix should follow onto the 3.x line.

Move homie.py, accumulator.py, and field_metadata.py from mqtt/ into a
new _impl/schema_0/ package, and split the flat-schema Homie constants
(topic formats, TYPE_*, lugs direction, circuit id helpers) out of
mqtt/const.py into _impl/schema_0/const.py. mqtt/const.py keeps only
transport-level constants (HOMIE_STATE_*, MQTT_* connection settings).

Pure relocation: only import statements changed in the moved files.
Public API is unchanged (HomiePropertyAccumulator re-exported from the
new location; __all__ verified identical before/after).
SpanMqttClient now builds its parser via an injectable adapter_factory
(defaulting to SchemaZeroAdapter) at the two points that need panel_size —
connect() and the pre-rebuild reconnect path — instead of constructing
HomiePropertyAccumulator/HomieDeviceConsumer and formatting flat-schema
topics inline. _require_homie becomes _require_adapter with the same
exception type and message; set_dominant_power_source still raises
SpanPanelServerError when no core node is found, now driven by the
adapter's set_dominant_power_source_topic() returning None. The
_on_connection_change resubscribe path uses a plain None-check instead of
_require_adapter() to avoid introducing a new raise path inside a
connection callback where none existed before.
Add discover_adapters(), a process-lifetime-cached registry populated
from the span_panel_api.schema_adapters entry-point group, plus its
self-registration for SchemaZeroAdapter. Not yet wired into the
factory (Task 6).
Tier 1 dispatch: data-model-version absence selects schema_0; presence
selects schema_{major}, raising SpanPanelAdapterMissingError if no
matching adapter is installed. create_span_client() resolves the
adapter class via discover_adapters() and passes it as SpanMqttClient's
adapter_factory. Adds schema_major, data_model_version,
schema_dispatch_reason, and available_adapters diagnostics properties,
and logs the selection on connect().
…strap

Move log_schema_drift out of _impl/schema_0/field_metadata.py into a new
top-level span_panel_api/schema_drift.py — it only diffs two
HomieSchemaTypes dicts and never touched the schema_0 TYPE_* constants,
so it was already schema-agnostic and belongs at the bootstrap level, not
behind the adapter seam. mqtt/client.py now imports it from there instead
of from _impl/schema_0, so the transport no longer reaches into the
adapter package for anything but its default adapter_factory
(SchemaZeroAdapter), which stays as the plan-sanctioned default.
build_field_metadata and the TYPE_* imports are untouched; the now-unused
_LOGGER/import logging left behind in field_metadata.py are removed.

Also documents two behaviors that were previously implicit or wrong:
- SpanMqttClient.adapter's docstring now notes that transport rebuild
  replaces the adapter instance, so property callbacks registered on the
  old instance do not survive and must be re-registered.
- SchemaAdapter's docstring no longer claims every method is called by
  SpanMqttClient — find_node_by_type and register_property_callback are
  never called from the bootstrap; they exist for external consumers.
refactor: Phase 0 — isolate flat-schema parsing behind SchemaAdapter
BREAKING CHANGE: HomieLifecycle, HomiePropertyAccumulator and
HomieDeviceConsumer are no longer exported from span_panel_api or
span_panel_api.mqtt.

All three are flat-schema-specific, not Homie-convention-level:
HomiePropertyAccumulator filters every topic against a single device's
prefix and stores node -> prop, which drops nearly every message under
parent/child; HomieLifecycle's members are not Homie 5 $state values but
a consumer-side progression encoding 'one description received => ready',
which is the flat readiness model. HomieDeviceConsumer is the flat parser
itself.

They were re-exported only because the bootstrap re-exported them; nothing
consumes them (the HA integration references none of the three, and this
repo's own tests import them from their defining modules). Removing them
severs two of the three bootstrap -> _impl edges that prevent shipping
schema_0 as a separate distribution. 3.0 is already a breaking bump.

test_public_api_unchanged.py is a two-way pin, so it is edited here in the
same commit; its docstring now frames it as a deliberate-change guard
rather than a no-change guard.
The transport's last import of a parsing implementation. mqtt/client.py
imported SchemaZeroAdapter purely to use as the default adapter_factory,
which is unsupportable once schema_0 ships as a separate distribution:
the import would fail in exactly the adapter-less install that entry-point
discovery exists to support.

adapter_factory becomes optional. When it is None, _build_adapter resolves
DEFAULT_ADAPTER_KEY through discover_adapters() and raises
SpanPanelAdapterMissingError if nothing answers to it. Resolution is lazy
by design: constructing a client must not require an adapter to be
installed, only building a parser must — so 'import
span_panel_api.mqtt.client' now succeeds with every schema_0 module
blocked, verified directly.

Behaviour is unchanged for every existing caller. A directly constructed
client still parses the flat schema; it just reaches the parser by name
rather than by import. resolve_adapter() moves to adapters.py (client
cannot import from factory — factory imports client) and factory's
_resolve_adapter_cls now delegates to it, so a missing adapter produces
one error message from one place.

Also declares SchemaAdapter.__init__. Construction was always part of the
contract — the transport resolves an adapter class and calls it — but
Phase 0's Callable[[str, int], SchemaAdapter] alias left the signature
unchecked against implementations; mypy caught this the moment the seam
became a type[]. The signature carries panel_size, a flat-schema concept,
and is the part of the protocol expected to change with schema_1; stating
it makes that a visible break rather than a runtime TypeError.
…ming flat

_select_adapter_key mapped every unparseable value to schema_0 with the
reason 'assuming flat'. A panel publishing '1', 'v1.0' or '1.0-beta' would
therefore be handed to the flat parser, which does not fail — it produces
plausible but wrong power and energy figures in Home Assistant. A wrong
number the user cannot see is strictly worse than an error they can.

Dispatch now distinguishes three cases rather than two:

- Absent: the flat-schema signal, unchanged. The property was introduced by
  the firmware that introduced parent/child, so absence is real evidence and
  must stay non-fatal — it is the common case in the field today.
- Present with an extractable major, canonical or not ('1', '1.0-beta'):
  dispatch on that major and log the deviation. This is not a guess; the
  major is what selects the adapter and it was read, not assumed. Refusing
  here would take a panel offline over a formatting difference, while the
  warning still surfaces a new firmware format before it becomes an outage.
- Present with no extractable major: raise.

Adds SpanPanelSchemaVersionError rather than reusing
SpanPanelAdapterMissingError, because the remedies differ. A missing adapter
is a known schema with no installed parser — install the package. This is a
schema whose major cannot be determined, so no adapter can even be named.

Dead in Phase 0 (data_model_version is hardcoded None) and live the moment
Tier 1 reads a real value.
discover_adapters stored whatever ep.load() returned without looking at
it. A module, function or instance registered where a class belongs passed
straight through resolve_adapter and failed later as an opaque TypeError
deep inside connect() — the failure mode SpanPanelAdapterMissingError was
introduced to prevent. It was also an Any crossing into a
dict[str, type[SchemaAdapter]], against the repo's no-Any standard.

Validation is a TypeGuard, so the Any from ep.load() is narrowed by a real
runtime check rather than assigned unexamined. The required-member list is
derived from SchemaAdapter itself rather than restated, so adding a method
to the protocol automatically makes it required of every adapter package;
issubclass is unavailable because the protocol has data members and
runtime_checkable rejects issubclass() for those.

The check is presence-only and deliberately so — a Protocol cannot express
signatures at runtime, so wrong arity still surfaces at call time. It
catches the failure that actually happens, which is a misdirected entry
point, and converts it to a named logged skip.

Skipped, never raised: one broken third-party adapter must not take down a
panel whose own adapter is installed and fine. Unreachable while schema_0
is the only registered adapter; live as soon as a second one ships.
Diagnostics move into the SpanMqttClient constructor. The factory patched
_data_model_version and _schema_dispatch_reason onto private state after
construction, which needed two protected-access disables and left a window
where a connected client could report a selected adapter alongside
schema_dispatch_reason='not dispatched'. They are now true from the moment
the object exists; constructing directly still describes exactly that.

SUPPORTS_DATA_MODEL_VERSIONS had two independent literals — the class
attribute the protocol requires and a module constant beside it — with
nothing asserting they agreed. The module now re-exports the class
attribute, so the class is the single source. A drift here would have been
invisible until a panel reported a version the adapter falsely claimed.

Exports SpanPanelAdapterMissingError and SpanPanelSchemaVersionError. Both
are errors a user actually sees when their panel outruns their install, so
catching them should not require reaching into a private module.

Deletes DEVICE_TOPIC_FMT, STATE_TOPIC_FMT, DESCRIPTION_TOPIC_FMT and
PROPERTY_TOPIC_FMT (dead before Phase 0 relocated them; the adapter reads
through one wildcard subscription and writes through the set pattern) and
TYPE_PCS (a real schema type this library does not consume).

Documents the two type namespaces in const.py, which are easy to conflate:
the schema's "types" block declares properties per type, while a node's
$description carries the type string actually on the wire, and they are
not the same set. TYPE_LUGS_UPSTREAM/DOWNSTREAM are real wire types
confirmed against a live panel in 1eef0dc but are absent from the schema,
which declares only the base lugs type — so each needs a _LUGS_FALLBACK
alias or property metadata silently comes back empty. Corrects the stale
"kept in sync with homie.py" reference to consumer.py.
BREAKING CHANGE: span-panel-api no longer contains a parser. Installing it
alone gives a client that connects and then raises
SpanPanelAdapterMissingError. Flat-schema panels need
span-panel-api-schema-0 installed alongside it.

This is what Phase 0's protocol seam was for. Phase 0 proved the transport
could delegate all parsing to a SchemaAdapter; it did not prove the parser
could be absent, because the bootstrap still imported _impl/schema_0 in
three places. Those were severed in the preceding commits, so the code can
now actually move.

Layout is a uv workspace: the bootstrap stays at src/span_panel_api, the
adapter becomes packages/schema-0 publishing span-panel-api-schema-0 with
its own version and README. The entry-point block moves from the root
pyproject to the adapter's — that single move is what makes the bootstrap
adapter-less; everything else is import rewriting. git mv keeps rename
detection, so the diff reads as a move (const.py is byte-identical).

Adds scripts/verify_adapterless_install.py and a CI step that runs it
against a venv holding only the bootstrap wheel. This cannot be a unit
test: the thing under test is installed distribution metadata — which
wheel carries the entry point, and whether the import graph reaches a
parser — and a test in the development workspace always has the adapter
importable, so it can never observe the failure it would be guarding.
Verified locally end to end: bootstrap alone imports and fails by name;
adding the adapter wheel makes discovery, construction and topic
generation resolve.

Two tool configs had to learn the repo has two source roots:

- vulture scanned only src/span_panel_api, so moving the adapter out made
  its protocol parameters look unused. It now scans both trees.
- pylint's wrong-import-order is disabled. pylint offers
  known-standard-library and known-third-party but no known-first-party, so
  it cannot be told that span_panel_api_schema_0 is first-party and
  disagreed with ruff on every adapter module. ruff's isort enforces the
  same rule and can be told the truth via known-first-party, so it becomes
  the single authority.

Versions go to 3.0.0b1 / 1.0.0b1 because the adapter declares a dependency
on the bootstrap and needs a real version to resolve against.
The local coverage hook passed --cov=src/span_panel_api explicitly, so
moving the adapter out of src/ silently dropped 543 statements from the
report — coverage looked fine at 91.6% while the entire flat parser went
unmeasured. Both source roots are now passed, and the real figure is 94%.
…ource

Design doc testing item 8, clauses 8a and 8b — the only signal that catches
adapter-axis drift before release. Every other symptom of "SPAN changed the
schema and we did not notice" reaches production as a silent absence: a
property that stops arriving, a metadata lookup that returns None, an entity
that goes unavailable with no error anywhere. The same failure already
happened upstream (python-sdk#27 was exactly a hardcoded fact that had
stopped resolving), which is why it is worth having with one adapter rather
than waiting for schema_1.

8b records the schema revision this adapter was written against
(sha256:d347556a07d98f40, spanos2/r202603/05) as SCHEMA_ANCHOR in the
adapter package rather than the bootstrap, because the field is renamed with
the block it covers: flat serves typesSchemaHash over `types`, parent/child
serves deviceClassesSchemaHash over `deviceClasses`. schema_1 declares its
own. The hash is content-derived, so it moves when the schema moves rather
than on every firmware build, which is what makes it an anchor and not noise.

8a checks all 64 (node_type, property_id) rows in _PROPERTY_FIELD_MAP
through the same lookup path build_field_metadata uses, plus HOMIE_DOMAIN /
HOMIE_VERSION against homieDomain / homieVersion. It also pins the two type
namespaces apart: TYPE_LUGS_UPSTREAM and TYPE_LUGS_DOWNSTREAM are real wire
types that the schema does not declare, so they are asserted *absent* from
`types` and *present* in _LUGS_FALLBACK — a wire-only subtype without an
alias silently yields no property metadata, and that is now caught.

Records one standing disagreement as an assertion rather than a comment: the
schema declares circuit active-power in kW and real panels publish W. The
instinct on finding that is to "fix" the code back to kW, which would
reintroduce the 1000x error 1eef0dc removed after checking real hardware.
The test fails if SPAN ever corrects the schema, and says to delete itself.

8c (does SUPPORTS_DATA_MODEL_VERSIONS still cover the reported version) is
deliberately absent: flat firmware publishes no version to compare against.
Records the two breaking changes users will hit — the bootstrap no longer
containing a parser, and the three flat-schema names leaving the public API
— with the install command that resolves the first.

The adapter changelog opens by stating which axis it versions on. That
package's number tracks the parser, never the wire format it parses; the
wire format is fixed and identified by SUPPORTS_DATA_MODEL_VERSIONS.
Confusing the two is the failure mode the two-axis split exists to prevent,
so it is worth saying in the file people read when deciding to upgrade.

Also records the two known deviations from the published schema (circuit
active-power in W not kW, and the two undeclared lugs subtypes) where a
consumer will actually look for them.
Phase 1 — ship schema_0 as its own distribution
The release workflow was never updated for the two-distribution layout. It
built the root package only and rewrote the root version from the tag, so
publishing 3.0.0b1 would have shipped a bootstrap with no adapter on PyPI —
every install connecting and then raising SpanPanelAdapterMissingError, with
the package that fixes it not existing.

A tag now selects a distribution rather than setting a version:

  vX.Y.Z           -> span-panel-api
  schema-N-vX.Y.Z  -> span-panel-api-schema-N

and the job fails unless the tag matches the version committed in that
distribution's pyproject.toml. Stamping the version at release time is not
extensible to two packages and is now actively wrong: the adapter declares a
dependency floor on the bootstrap, so both committed versions participate in
resolution and cannot be treated as placeholders.

The adapter distribution shipped without a py.typed marker — it did not travel
with the code when the parser moved out of src/span_panel_api, which has one.
Fully annotated, strict-clean code resolved as Any for every downstream
consumer. Verified against installed wheels: strict mypy reported
import-untyped and 'Revealed type is "Any"' before, real types after. Both CI
and the release job now reject a wheel built without the marker.

Two review findings alongside:

- factory dispatch returned the literal "schema_0" while the transport's
  default path used DEFAULT_ADAPTER_KEY. Two sources of truth for the key the
  two callers of resolve_adapter must agree on, and a divergence is invisible
  in a dev workspace where every adapter is installed.

- required-member derivation screened vars() for callable, which reads as
  equivalent to "every declared member" and is not: property and classmethod
  objects are not callable, so a protocol member of either kind would have
  stopped being required without anyone noticing. The derivation is extracted
  into a function so the rule is testable against a synthetic protocol rather
  than asserted in a comment.

432 tests pass, coverage 94%.
…ribution

PyPI trusted publishing is configured per project, so span-panel-api-schema-0
needs a pending publisher created before its first release or the publish step
fails on an otherwise correct build.
fix: make the release publishable and the adapter type-visible
The repository publishes more than one PyPI distribution from one source tree,
and nothing recorded how that works. Every release would have meant
re-deriving it from the workflow.

RELEASE.md covers the layout, why the bootstrap and adapters version on
separate axes, how a tag selects a distribution and its manifest, the
single-distribution and whole-workspace procedures, what has to be set up on
PyPI before a new adapter's first release, what each failure message means, and
how to verify a release from PyPI rather than from CI.

Also fixes the README's install instructions, which still said
`pip install span-panel-api` alone. On this branch that produces a client with
no parser that connects and then raises SpanPanelAdapterMissingError.
docs: release runbook for the multi-distribution layout
The guard that refuses a parent/child panel was written, tested, and never
invoked: create_span_client hardcoded data_model_version = None, so every
panel resolved to the flat parser no matter what it reported. A v1.0 panel
did not fail cleanly either — the flat parser reached for
energy.ebus.device.circuit/space, which parent/child firmware keeps under
deviceClasses, and the run died on "Schema missing
'energy.ebus.device.circuit/space' property": a complaint about a missing
property, for a panel whose actual problem is that nothing installed can
parse it.

The Homie schema is now fetched over REST before the broker is opened, and
its dataModelVersion selects the adapter. SPAN confirmed the absence of that
field on this endpoint is a reliable flat-versus-parent/child signal,
mirroring MQTT's info/data-model-version, and that dispatching on it before
opening MQTT is supported. A 1.0 panel now raises
SpanPanelAdapterMissingError naming the adapter to install.

Dispatch also moved to wherever a parser is built, not just the factory path.
A directly constructed SpanMqttClient — which the README documents and the
integration uses — previously always resolved the flat adapter, carrying the
same defect the factory had.

The protocol changes shape once, here, because this is the release that
breaks it:

- __init__ takes the schema rather than panel_size. Deriving panel_size means
  reading a block only the flat schema has, so the bootstrap had to
  understand a wire format it is meant to know nothing about, and an adapter
  shaped differently had no way to say so.
- build_field_metadata() takes no arguments; the adapter holds its schema.

Tier 1 dispatch moved to span_panel_api.dispatch so the transport can reach
it without importing the factory. adapters.py still answers "what is
installed"; dispatch.py answers "what does this panel need".

Also pins the enum-tolerance rule that schema_1 inherits: v1.0 requires
consumers not to raise on an unrecognised value in a $format-extended enum,
which is the opposite of the version rule one import away. The difference is
blast radius — an unknown enum member affects one property, an unknown schema
version means the whole tree may be misread.

438 tests pass, coverage 94%.
The check builds a parser against a real bootstrap-only wheel, so it still
passed a panel size and died on AttributeError before reaching the error it
exists to assert. The unit suite could not catch this: it never runs against
an install that has no adapter.

It now passes a flat schema, which is also the case this check is about —
every panel in the field reports no data-model-version, so dispatch asks for
the default key and finds nothing providing it.
feat: dispatch on the panel's real data-model-version
Phase 2 tasks 1-3.

Controller normally holds an MQTT client and subscribes as it walks a tree —
the root first, then each child as it announces. A SchemaAdapter cannot work
that way: the transport builds the parser at client.py:206 and the connection
does not exist until :242, and the parser is never handed one. So a parser has
no way to subscribe to anything, let alone to keep subscribing as it discovers
children.

It turns out not to need one. Controller is given a transport that only records
its subscriptions, and the adapter asks for a single broad subscription up
front through the existing topics_to_subscribe() — which is exactly what the
flat adapter already does with ebus/5/{serial}/#. Messages arrive through
handle_message and are routed to whichever SDK callback asked for them.

Verified against a real panel_sim tree in the library's own order of
operations: parser built with no connection, one wildcard requested, 13 devices
and 11 children discovered, root ready, zero connection access from the parser.

This also removes the failure mode that made task 3 the risky one. There is no
hand-wired resync hook to forget, because the transport re-subscribes the same
static list on every reconnect and the broker replays the retained tree. A
missed resync would have produced stale readings rather than an error.

Task 1 is therefore reverted: with subscriptions never revised and nothing
retained, AsyncMqttBridge needs neither unsubscribe() nor a retain parameter,
and adding public API with no caller is worse than not adding it.

Two things the tests caught. Re-recording a subscription kept the dict key's
original insertion position, leaving a re-registered specific pattern behind a
broader one in match order. And the ordering rationale was overstated:
tree-rooted discovery records four device-scoped patterns per device which
cannot overlap, so most-recent-wins is defensive — it matters only for the
SDK's wildcard mode, and the docstring now says so.

No entry point is registered. Resolving schema_1 to a package that cannot build
a snapshot would turn a clean SpanPanelAdapterMissingError into an opaque
failure later. It lands with the mapper.
First half of the snapshot mapper. Driven from a real panel_sim parent/child
tree captured as tests/fixtures/parent_child_tree.json, so the shapes are the
firmware's rather than an invention.

Three snapshot fields no longer have a property to read, because v1.0
consolidates four flat mechanisms into two. The migration guide defines the
derivations, so they are followed rather than guessed:

  always-on     = not switch/relay-controllable
  never-backup  = not $settable on load-shed/priority
  sheddable     = priority != NEVER and relay-controllable

never-backup is the interesting one: v1.0 expresses it as *mutability*, so it
is read from the description's $settable attribute rather than a value topic.
That also settles the Phase 1 note that schema_1 must accumulate settable
attributes — it must, and this is why.

Both defaults are chosen so silence cannot invert a fleet. An absent
relay-controllable means controllable, and an absent $settable means settable,
because each property exists to announce the exception; defaulting the other
way would mark every circuit on a panel uncontrollable or never-backup.

Two improvements fall out of the new schema. Tabs come from info/spaces, which
publishes the occupied spaces literally ("36,38") where the flat schema
published one space plus a dipole flag and left the consumer to infer space+2 —
so a 3-pole breaker now reports three tabs instead of being truncated to two.
And active-power is declared in W, so the flat schema's kW-vs-W deviation does
not carry forward.

Sign handling is unchanged and deliberately so: the enclosure reference frame
means a load reads negative active-power and accumulates exported-energy, both
the reverse of what the names suggest, so power is negated and the energy
accumulators are swapped.

Two tooling fixes this surfaced. The mypy hook gains ebus-sdk, which it needs
to resolve the SDK's types in its isolated environment. And pylint gains an
init-hook listing every workspace source root: it previously resolved
span_panel_api only when a run happened to include a file under src/, so
committing an adapter package on its own reported import-error for imports that
are fine.

466 tests pass, coverage 94%.
Second half of the mapper's read side. v1.0 spreads what the flat schema kept
on one device across the panel and its children: the grid connection is the
upstream lugs device, feedthrough is the downstream lugs, and grid state moved
to the MID, which is where islanding is actually decided.

Direction is per-device and the two rules are opposites, so they are kept
apart rather than sharing a helper. A circuit needs flipping because the panel
exports to a load; the lugs do not, because the panel imports from the grid and
the enclosure frame already reports import-positive there. Reading the lugs
with the circuit rule would invert every grid figure while leaving it entirely
plausible.

Retired panel fields are left None rather than substituted. dominant-power-
source split into grid-forming-entity plus asserted-islanding-state and
grid-islandable was removed outright; picking a stand-in for either would be a
silent product decision.

panel_size has no v1.0 source at all, which this work established rather than
assumed: the flat schema carried it in the Homie schema's `space` format
("1:32:1"), its successor info/spaces is a plain string with no format, the
panel's info node publishes no size, and the migration guide never maps one.
The highest occupied space is implemented as an explicit lower bound — a
40-space panel whose highest occupied slot is 36 reports 36 — so unmapped-tab
synthesis is not reproducible from the wire. Parsing info/model ("MAIN_40") was
rejected as undocumented vendor parsing that breaks silently. Recorded in the
entity and config deltas write-up as needing a product decision or an upstream
question.

Lugs are located by info/direction rather than device id: the ids in the
reference tree are the simulator's naming, the direction property is what the
schema defines.

481 tests pass, coverage 94%.
`test_the_vendored_captures_match_the_simulator` is what catches a vendored
capture going stale while the producer moves on. It has never run. No workflow set
`PANELBENCH_DIR`, the developer `.env` named a directory that did not exist, and a
skip renders in a summary line exactly like a pass. The same was true of
`EBUS_SPEC_DIR` and the byte comparison of the vendored catalogs.

Three changes, because there are two different questions here and one hole between
them.

**ci.yml clones both peers at the commits `spec_lock.json` pins.** That asks a
question with a deterministic answer -- do our vendored bytes match the commit we
claim they came from? -- which is answerable on any commit and fair to block a
merge on. It catches an accidental local edit to a vendored file. The commits are
read out of the lock file at run time by `.github/actions/peer-checkouts` rather
than written into the workflow, so the pin keeps exactly one home; a workflow that
restated a commit would agree with the lock file right up until someone re-vendored
and updated only one of them. Both repositories are public, so no token is involved.

**The skip helper fails when `CI` is set.** Locally a skip is right -- not every
developer keeps sibling checkouts. In CI it means the wiring came undone, and
skipping on that is how a check gets switched off by an environment that stops
supplying a path. The three states stay distinct: unset, a path that is gone, and a
checkout reaped to an empty skeleton each call for a different fix and each keeps
its own message. `CI` rather than a variable of our own, because a runner already
sets it, so switching this off means opting out of being an environment.

`test_an_unconfigured_peer_checkout_fails_in_ci_and_skips_locally` holds both
halves, in both environments, across all three states. Asserting only the CI half
would leave the local half free to become a failure, which is the change that makes
someone delete the check rather than configure it.

**peer-drift.yml asks the other question, on a schedule, never on a pull request.**
Whether the producer has moved past the pin needs panelbench's current head, and
that answer changes because someone else pushed. Failing an author's unrelated
change for it would teach everyone to ignore it. So it clones `peer.ref`, reports
"branch is at X, we pin Y, N commits behind" with the subjects in the job summary
whichever way it goes, and then runs the *same* conformance file the blocking job
runs -- reusing it rather than reimplementing a diff, so there is no second
definition of "the captures match" to drift. A red run therefore means the producer
changed something we vendor, not merely that it advanced.

Also fixes a NameError in the catalog comparison's failure message, which would
have raised instead of reporting the first time CI made that check run.

802 passed; pre-commit clean.
…logs

Sixteen capability catalogs have been vendored since v1.0 landed and were
read only to assert that a catalog *exists* for every node the adapter
addresses. Nothing opened the definition. So the comparison that catches a
mislabel -- does the `unit` and `datatype` a producer declares for a
property agree with the catalog's? -- was never made, and the one mislabel
this repository has met was found by a person noticing that a sibling
device declared the same quantity differently: `meter/active-power` in
`kW` while the values are watts, a 1000x error that shipped.

`span_panel_api_schema_1.catalog` makes that mechanical. It compares one
declaration against one catalog definition and classifies the result;
`tests/test_catalog_divergence.py` runs it across the four vendored
producer captures and holds the outcome against an acknowledged-divergence
register. Catalog definitions are passed in rather than read by path: the
vendored spec is outside this distribution's wheel, and taking them as an
argument is what would let a live-panel diagnostic reuse the same rules.

Agreement is silence. Disagreement is surfaced and never silently resolved
in either direction -- a finding is not a licence to change a wire reader
to match the catalog, nor to assume the catalog is right. It is recorded
with what the wire says, what the catalog says, which producers show it, a
reason and a date, and it fails in both directions like every other
baseline here: a new divergence fails until somebody records it, and a
recorded divergence that has *disappeared* fails until its line is removed.
The second direction is what keeps the register self-cleaning rather than a
suppression list.

Two rules keep it from producing false findings, and both were measured
rather than assumed:

- An abstract unit is a dimension. `soc/soe` and `info/nameplate-capacity`
  are `unit: "energy"`, which the specification requires a publisher to
  substitute a real unit for -- a BESS in kWh, a water heater in Wh. A
  member of the enumerated family is silent; echoing the placeholder back
  is not. A catalog unit token that is neither a known family nor a known
  concrete unit fails until a human classifies it, so a new abstract family
  upstream cannot arrive as sixty false findings.
- An absence is terminal. A property no catalog defines -- the EVSE's
  `config` node, which is not an eBus capability at all -- has no
  definition to disagree with, so it is reported once as absent and never
  as a field mismatching against nothing. `_SPAN_EXTENSIONS` stays the
  single home for the read-set half of that question.

The flat schema document is surveyed too, because it is where the known
mislabel actually lives. It has no capability nodes, so its properties
reach the catalogued vocabulary through the snapshot field path both
adapters' metadata tables already name -- derived from those tables so the
join cannot outlive them -- and only where the two spell the property
identically. Fifteen flat properties reach a catalogued property under a
different name (`dipole` for `breaker/poles`), and comparing across a
rename would invent divergences out of the pre-catalog spelling that having
two adapters already handles.

Measured over the four captures: 2 divergences across 61 compared
properties, 3.3%, in two shapes -- one unit, one datatype. Both are
seeded in the register.
…nothing from

`test_declared_but_unread` in the integration already asks this question and
answers it by experiment. It is the right check and it is fixture-bound: a real
panel that starts publishing a property fails nothing until somebody recaptures.
This is the same question asked of the tree in front of the user.

`build_field_metadata` now returns a second kind of row alongside the curated
ones. For every property a device's `$description` declares that this adapter
addresses nowhere, a row under the `discovered.` namespace carries the declared
`datatype`, the declared `unit`, and whether a value has been published for it.
Never the value: these rows exist to be forwarded in a consumer's diagnostics,
which leave the machine they were generated on, and a consumer's redaction is
key-based and knows nothing about wire names.

Additive by construction. No `SchemaAdapter` member and no `ADAPTER_CONTRACT`
bump -- `_derive_required_members` makes every public protocol member required
of every adapter distribution, so adding one would reject every built wheel. An
adapter emitting no such rows is indistinguishable from one built before the
namespace existed, and a consumer that never partitions sees exactly the curated
rows it saw before.

Namespaced rather than flagged because the failure being prevented is a silent
one. A consumer's curated inventories are keyed by snapshot field path, and a
discovered row reaching one would read as a produced field nothing renders --
the shape of a real defect.

**The report is only as good as the enumerations behind it, so they are proved.**
"Addressed" comes from four tables: `_PROPERTY_FIELD_MAP`, the lugs direction
tables, the charge-limit resolution, and the new `_CONSUMED_WITHOUT_A_ROW` --
the forty properties the snapshot mapper reads that carry no metadata row
because they are identity, topology, or a qualifier rather than a reading.
Without that fourth table the only enumeration of what schema_1 reads was the
metadata map, which is a third of it, and the report would have been 52 rows of
which 42 were false. A stale entry there fails *silently*, by keeping a property
out of the report, and a smaller report looks exactly like a panel with nothing
new on it.

`test_schema_one_discovery.py` closes that by experiment: it republishes every
property the reference tree declares with a legal different value, rebuilds the
snapshot through the real mapper, and asserts both directions -- every
claimed-read property moves a snapshot field, and every reported property moves
none. Together those pin the output exactly. `_CONSUMED_OFF_SNAPSHOT` holds the
three declarations consumed by a route no snapshot field can show; each names
the code that reads it, and each fails the day its property does move a field,
which is what keeps it from becoming an allowlist.

The flat adapter emits none, deliberately: its metadata comes from the REST
`types` document, the superset across all hardware rather than what one panel
has, so "declared and unaddressed" there would describe the schema document.

Against the reference tree the report is nine properties -- the `connection/
count` pair no producer publishes, the two deliberate `status` skips, the four
redundant `*-device-type` echoes, and the PV serial held out of the device id.
TreeRoles sorts the tree into roles and anything matching none of them has
always fallen off the end silently. The schema is vendor-extensible, so a
device type nothing here models is an expected arrival rather than a
hypothetical, and that silence is the only thing a consumer can render for it.

Adoption is scoped to a device, never to a property. A new property on a
modelled device is a curation task with a short turnaround, and surfacing it
automatically spends a consumer's entity identity permanently on a shape a
human would likely have chosen differently. Extra instances of a modelled
type stay unadopted for the same reason: a second BESS is a multiplicity
limit, not an unmodelled device.

info and connection resolve to the device card and the device link rather
than to readings, keyed on the node because the catalogs carry no marker for
a device reference and a hard-coded name list goes stale silently.

AdoptedProperty carries the value where DiscoveredMetadata must not: those
rows are forwarded in diagnostics, which leave the machine. Separate types so
conflating them is a type error.

Additive: adopted_devices defaults empty, so schema_0 is untouched and the
adapter contract does not move.
AdoptedProperty.set_topic is populated only for a settable property on a
device is_modelled rejects, so the scoping is the authorisation rather than a
check a caller has to remember. set_adopted_property resolves the property
against the current snapshot and publishes to the topic it carries, accepting
no topic from its caller.

The alternative was a set_property_topic member on SchemaAdapter, rejected
twice over. It would put every curated control one argument away, and two of
them do real work on the way out: dominant_power_source_payload translates
GRID into ON_GRID, and evse_charge_limit_payload refuses a value above the
commissioned ceiling. And _derive_required_members would make it required of
every adapter package, so an older adapter wheel would fail at discovery
rather than losing one feature.

No translation and no bounds check on an adopted write: the declaration is all
this library knows, and inventing a bound would invent a fact about somebody
else's hardware.

Versions bumped across all three packages because the in-tree b5 was never
published and would otherwise mean two different things.
AdoptedDevice gains parent and proxied. Neither changes topology: an adopted
device is still registered under the enclosure. They exist because a proxied
unmodelled device is a real shape that would otherwise be flattened away
without leaving evidence. The reference tree already contains one -- bess-mid
declares parent: bess, the {proxier-id}-{proxied-id} naming of devices/proxy.md.

proxied is derived against the tree root in the adapter rather than left to the
consumer, because device ids are opaque and a consumer holding one device
cannot tell the enclosure's id from a sibling's.

The nesting is deliberately not built. python-sdk#49 records that proxied ids
differ by design and that consumers correlate by info/serial-number rather than
by device id, and ebus-sdk 0.21.0 shipped DeviceSpec/DeviceTreeBuilder
(python-sdk#57) with the graph builder still to be reconciled against it. The
tree model is being reshaped upstream, so the fields capture the evidence and
the topology waits.
The schema-1 pin is already a range, so only the lock moves. 0.21.0 adds
DeviceSpec and DeviceTreeBuilder and documents mqtt_cfg=None as the passive
mode -- a tree that composes its description and resolves ids without opening
a socket. Nothing here changes: 875 pass and mypy is clean on it.
The peer-conformance check caught this rather than a person: panelbench moved
its spec pin to 4085c68 and this repository still recorded 4254526, so the two
sides were reading different vocabularies. That check has been loud since it
was wired to fail rather than skip, and this is the first time it has fired on
a real move.

All sixteen catalogs re-vendored byte-for-byte from the specification at that
commit, the peer record updated to panelbench 6c649e5, and nine capability
versions read out of the catalogs rather than typed: breaker, connection, grid,
grid-forming, power-flows and soc to 0.2, info to 0.3, switch to 0.3, meter to
0.4. Framework 0.7 -> 0.9.

Nothing in the adapter changes. The catalogs are a validator, and the two
divergences the register records are unaffected.
…ption

Two comment corrections, no behaviour change.

power-flows/battery is charge-positive. The catalog said discharge-positive
when this was written; capabilities/power-flows.md 0.2 corrected it to the
frame the firmware always published. Pass-through was right either way, so only
the stated reason was stale -- but a reader deriving a sign from that sentence
would have got it backwards.

_charge_positive assumes the BESS child publishes its own meter
spec-conformantly, discharge-positive. The eBus maintainer's r202633
conformance note says SPAN does not: it publishes charge-positive, so power_w
inverts on that firmware. Named rather than compensated, because the simulator
was fixed to be spec-conformant and the panel was not, so no test here can see
it and a live panel is what should settle it.
_charge_positive produced discharge-positive values, and said the opposite in
its name, its docstring and the module rule above it. Renamed to
_discharge_positive; the value itself does not move.

That value is correct. Positive means power flowing out of the battery, which
is the frame the eBus specification asks of a device's own meter. What was
wrong was the claim that the into-the-device rule the circuit fields follow
held here too: the wire inputs are in opposite frames, so the same single
negation lands the two conventions on opposite results.

Settled by measurement rather than by reading a catalog. A producer driven into
self-consumption with the grid at zero -- PV 4181 W plus battery 1917 W meeting
a 6099 W load -- leaves no room to argue which way the battery is going. The
wire publishes -1917.49 and this reports +1917.49 while discharging.

The module docstring also records what the same run confirmed about the two
wire properties: power-flows/battery and the BESS meter carry the SAME sign as
each other, which is the specification's violation rather than its rule, and is
therefore the discriminator that would tell a consumer which firmware it is on.
…g dev tooling

Release-readiness review before publishing three distributions to PyPI. Four of
these cannot be fixed after a version is published, which is why they are here
rather than in the next one.

**httpx was unbounded and httpx 1.0 removes `AsyncClient`.** 1.0.dev1 through
dev4 are on PyPI now, and every distribution here is a prerelease, so
`pip install --pre` -- the verb RELEASE.md itself prescribes for verifying a
release -- resolves them. `_http.py` constructs `httpx.AsyncClient` at runtime,
so auth, detection, `get_homie_schema` and the redispatch refetch all raise
AttributeError. Reproduced in a clean venv: 55 failures, every one the same
missing attribute. `paho-mqtt` has been bounded from the start; this was the one
unbounded runtime dependency.

**Upgrading the bootstrap alone bricked an existing install, and pip reported
success.** `SchemaAdapter` gained two members after b3, and
`_derive_required_members` makes every public member mandatory of every adapter
wheel, so b6 with b3 adapters rejects both at discovery and no panel of any
generation connects. The contract integer cannot help: it is declared by the
adapter, so bumping it produces the same rejection at the same moment. Neither
mechanism can reach pip. Extras can, and extras cannot be retrofitted to a
published version, so they go in now.

**The eBus SDK ceiling tightens to `<0.23`**, the versions actually tested. 0.22
was read module by module first and changes only publisher-side code --
`adapter.py`, `topology.py`, `transport.py` and `property.py` are byte-identical
to 0.21 -- so this bounds exposure rather than reporting breakage. 0.x carries no
compatibility contract and this release reaches hosts that are slow to iterate;
widening a ceiling later is a patch release, narrowing one after a host has
already resolved a bad pairing is not.

**The wheel shipped `scripts/` at the top level of site-packages**, so an
unrelated `import scripts` in a Home Assistant venv resolved to this
distribution, and a markdown formatter installed as a console script for every
user. **The sdist shipped both adapters in full**, contradicting the one
invariant this distribution is built around. Both are now explicit, and the
sdist includes are anchored -- an unanchored `README.md` is a glob that matches
at any depth and pulled each adapter's metadata back in.

**A redispatch could fire twice for one firmware upgrade.** The in-flight guard
was released when the schema fetch finished rather than when the swap did, and
the slowest step sits inside that window: `_preload_adapter` imports the new
parser in a thread and takes seconds on a cold start, during which the recorded
generation is still the old one. A second retained `data-model-version` message,
or the connect edge, scheduled a second redispatch. The consumer reloads its
config entry off that callback, so it was a reload racing its own teardown --
during precisely the upgrade this release exists to support.

**The changelog documented the battery sign backwards.** It said `power_w` is
charge-positive and asserted a deliberate asymmetry with `power_flow_battery`.
The code says the opposite and the code is right: measured with the producer in
self-consumption and the grid at exactly zero, the wire read -1917.49 while the
battery discharged and the snapshot reported +1917.49. The two frames agree
rather than oppose. Whoever built a battery sensor from that note would have
shipped an inverted entity. The rename that settled it is now recorded in
schema-1, where the behaviour lives.

All three changelogs gained a section for the version being published; schema-0
and schema-1 had shipped b4 and b5 with none, and schema-0's floor claim still
named 3.0.0b2 against a manifest requiring b4.

One surviving mutation closed: replacing the device-id filter in
`_adopted_property` with a constant left the entire suite green. The lookup
returns the first device carrying the node and property asked for, so without it
a write aimed at one adopted generator publishes to another's topic. Every
existing test used a single adopted device, where the filter cannot be wrong
because there is nothing else to match.
…n point

`SpanPanelSnapshot.lugs_at_service_entrance`. `instant_grid_power_w` is the
upstream lugs' `meter/active-power`, and the name holds only where those lugs
are the service entrance. Two ordinary topologies break it: a BESS wired ahead
of the main lugs, and an enclosure fed by another enclosure. In both the lugs
meter panel-side flow while the utility side differs by whatever the intervening
device contributes or absorbs, so `instant_grid_power_w` and `power_flow_grid`
legitimately disagree -- and before this a consumer seeing them disagree had no
way to tell a topology from a fault.

Sourced from the lugs' own `connection/fed-by-device-id`, which the
specification names as the detection mechanism: `power-flows` 0.3 qualified its
negation table to say the `grid` row holds "only where the lugs are the utility
connection point" and pointed consumers here. This parser already read that
property and then discarded it -- it fed relative position and nothing else --
so the fact existed nowhere a consumer could reach.

The reference capture turns out to be one of these topologies. Its upstream lugs
publish `fed-by-device-id: bess`, so the reference panel reports `False`: the
producer wires the battery ahead of the main lugs and computes `power-flows/grid`
from the lugs together with the BESS rather than by negating the lugs. That makes
the capture falsifiable in both directions without being contrived, and it is
pinned as its own test, because a reference capture is usually the simple case
and this one is not.

A boolean rather than the intervening device's id: what a consumer needs is
whether to trust the lugs as grid. Naming the device would invite a second,
weaker inference about what is upstream, which the enclosure-chain case cannot
support at all -- the feeding device is another panel with its own tree, not a
child of this one.

Defaults `True`, which is a fact rather than an optimism. Flat firmware predates
chaining and publishes no way to express it, so a flat panel's lugs are its
service entrance; schema_0 leaves the field alone. Pinned by a test, because
mutating the default alone left the whole suite green.

Additive, so it costs neither a `SchemaAdapter` member nor an
`ADAPTER_CONTRACT_VERSION` bump, and appended rather than inserted so positional
construction does not shift.

Two comments and the README asserted the pre-0.3 rule flatly -- "upstream lugs
are the grid connection" -- and are corrected. The four `connection` property
names move to `const`, because `panel` now reads one and `devices` imports
`panel`.
Correcting an over-correction of my own. The original entry was wrong in one
clause -- it called `power_w` charge-positive -- and right in the next, that it
is deliberately opposite to `panel.power_flow_battery`. Fixing the first, I
flipped the second as well and claimed the two agree.

They do not. The enclosure's arbitrated figure is passed through untouched by
both adapters and is charge-positive: it reads negative for the same discharging
battery that makes `power_w` positive. The two are the same physical power in
different frames, and a consumer rendering both negates one of them -- which is
what the Home Assistant integration does at the entity, landing both of its
battery sensors on discharge-positive.

No shipped value is affected by any of this, and none ever was. The negation in
`build_battery` has always been there and has always been right; only the helper
name and these notes ever asserted a direction the code did not hold.
…ill parses

Live firmware publishes integer literals for `float` properties, and does it
inconsistently within a single node: a service-entrance capture arrived with
`power-flows/pv` as `-2434`, `battery` as `0` and `grid` as `-310`, while `site`
beside them read `2744.0`. All four declare `datatype: float`. An integer literal
is a legal float payload under Homie 5, so this is firmware being terse rather
than wrong -- but nothing can be inferred from a sample of one property, because
the siblings disagree.

The shipped parser already handles it; `float()` does not care. The test exists
because no producer we develop against does this. The reference emitter publishes
a decimal point every time, so a stricter parse would pass the entire suite while
silently dropping three of the four site flows to `None` and reporting the panel
as having no power-flows node at all. Mutating `number()` to require a decimal
point now fails 23 tests instead of none.

The capture that turned this up also confirms `lugs_at_service_entrance` against
hardware for the first time: that panel's upstream lugs publish
`fed-by-device-id` naming its BESS, and running the shipped parser over the
capture reports `False`, with the four power-flows terms summing to exactly zero.
The provenance checks added on this branch clone the eBus specification and
panelbench into `peers/`, and markdownlint-cli2 scans the tree through its own
`globs` rather than through the files pre-commit hands it -- so being gitignored
did not keep them out. 835 findings in upstream's prose failed the job before the
test step ran, which is the same failure shape the vendored spec exclusion above
already exists to prevent, at whole-repository scale.

Upstream's line lengths are not ours to correct, and a run that cannot reach its
tests is worse than no run: it looks red for a reason that has nothing to do with
what is being released.
`SpanMqttClient` and `create_span_client` accept an optional `httpx_client`.
Optional and defaulted, so nothing outside Home Assistant changes.

Four config-flow-facing entry points -- `detect_api_version`, `register_v2`,
`download_ca_cert`, `get_homie_schema` -- have taken an injected client all
along. The runtime path was the one that did not, so every schema read built a
throwaway: once at connect, and once per attempt inside
`_fetch_schema_with_retry`, which is the loop that runs while a panel is
mid-reboot after a firmware upgrade and can go five times in a row.

The integration's own `quality_scale.yaml` declares `inject-websession: done`,
and that was true of the config flow and of nothing that ran after it. This is
completing a pattern that already existed rather than introducing one, which is
also why the shape is a constructor argument: passing a pre-fetched `schema=`
would sidestep only the connect fetch and leave the retry loop untouched, and a
module-level setter or a factory callable would be new indirection for a problem
neither solves better.

The ownership rule is the one the existing entry points already state, and it is
the whole contract: a client handed in is never closed here, and its timeouts,
limits and headers are the caller's -- which is why the per-call `timeout`
defaults are documented as ignored when a client is injected. Home Assistant's
shared client carries httpx's default timeout rather than this library's 10 s.
That is a real change and it is the caller exercising policy it owns, not a
setting going missing; the retry loop is what absorbs it on the path where
patience matters.

Verified there is nothing to close: `_get_client` yields an injected client from
an early return that the `async with` never wraps, and no `aclose` appears
anywhere in the library. A test pins that, because relying on Home Assistant to
guard its own client is relying on the caller.

No contract impact -- `_derive_required_members` reads the `SchemaAdapter`
Protocol, and `SpanMqttClient` is a consumer of adapters rather than part of it,
so `ADAPTER_CONTRACT_VERSION` stays at 1 and neither adapter distribution moves.
Root only: 3.0.0b6 -> 3.0.0b7.

Three mutations verified dead: dropping the stored client, and un-injecting each
of the two call sites independently.
…t the clone

The docstring said "byte comparison against the specification at
`synced_commit`" and the code read the checkout's working tree. `synced_commit`
appeared only in the failure message, so the check measured whatever that clone
happened to be sitting on. Wrong three ways, one of them dangerous:

* it FAILS when the clone has moved ahead of the pin -- ordinary currency drift,
  not a defect here. Observed the morning the specification went to
  `power-flows` 0.3, for a change that altered nothing we vendor but a version
  string.
* it FAILS SPURIOUSLY with the clone on an unrelated branch.
* it PASSES FALSELY with a clone itself stale at the pinned commit while the
  specification has moved on -- the case where this check is the only thing that
  would have told you.

Now read out of git at `synced_commit`, so the answer does not depend on the
clone's state. A clone that cannot resolve the pin fails with a fetch
instruction rather than skipping, because a silent skip reads exactly like a pass
on the one check that proves the vendored bytes are what the lockfile claims.

The subprocess strips `GIT_*` from its environment, and that is load-bearing
rather than tidy: `git -C <dir>` does not beat an exported `GIT_DIR`, and git
hooks export one pointing at the repository being committed to. Without the
strip this read *our* object store, failed to find a specification commit there,
and demanded a fetch for a commit the clone already had -- under pre-commit only,
which is where it was caught.

Integrity, deliberately not currency. Whether upstream has moved past our pin is
a separate question whose answer is normally "yes, a little", and it must not
fail a build.

The upstream reference producer found and fixed the same defect in its own copy
of this check (distribution-enclosure-simulator #47, merged today); the framing
here follows it.

Verified by mutation: tampering with a vendored catalog fails and names the file;
moving the pin to a commit we did not vendor from fails. Passes with the clone at
the pin, at a commit ahead of it, and under a simulated hook environment.
The `<0.23` ceiling set earlier today excluded the current SDK on the day it was
set: 0.23.0 and 0.23.1 both shipped within hours of it. A bound that stale on
arrival is worth re-deriving rather than defending.

Re-checked the same way as 0.22 rather than extrapolated from it. Diffing the
0.22.0 and 0.23.1 wheels module by module, exactly two files differ:
`__init__.py`, by the version string alone -- so no export changes -- and
`declaration.py`, the declarative builder. This distribution's whole SDK surface
is `Controller`, `homie.DiscoveredDevice` and structural conformance to
`MqttControllerTransport`; nothing here imports `declaration`, and every match
for that word in our source is prose in a docstring. The suite is green against
0.23.1 with no source change.

The comment now records the method rather than the conclusion, because the
conclusion expires. It also records the cost honestly: this bound buys a release
here per SDK minor, and the SDK is currently shipping several a day.
…atch

Caught on a live firmware upgrade, on two Home Assistant instances watching one
panel. Both stayed on the old parser; neither recovered without a manual reload.

    11:22:07  MQTT disconnected abnormally
    11:25:15  Client rebuild - CA fetch failed: HTTP 502
    11:26:15  MQTT reconnects, redispatch fires
              get_homie_schema -> HTTP 502 -> SpanPanelAPIError
              Task exception was never retrieved

`_fetch_schema_with_retry` exists because a panel accepts MQTT before it serves
HTTP. It caught `SpanPanelConnectionError` and `SpanPanelTimeoutError` -- two of
the three ways that manifests. The third is the one a real reboot produces: the
panel answers, with 502, because a booting device brings its network stack and
reverse proxy up before the application behind them. That exception was not in
the except clause, so the FIRST attempt raised out of the retry loop, out of the
fire-and-forget task, and the parser was never swapped -- the exact failure the
redispatch was written to prevent.

Three changes, each mutation-verified:

* `get_homie_schema` raises `SpanPanelServerError` for any 5xx, carrying the
  status. "Not ready yet" is a different fact from a 4xx, which will not fix
  itself, and the retry must be able to tell them apart. The class already
  existed and was not used here.
* The retry catches it, and the window is sized from the observed reboot rather
  than guessed: five attempts capped at 8s gave up after ~23 seconds against a
  panel that took four minutes to return and was still serving 502 then. Twelve
  attempts backing off to 30s covers it.
* Nothing escapes the redispatch task. An unexpected failure surfaced as a bare
  `Task exception was never retrieved` while the parser silently stayed put. Now
  logged at ERROR naming the consequence and the remedy, because a reload is the
  user's only move and nothing else was going to say so.

Also closes the provenance chain that `ebus-panel-sim` 0.6.1 unblocked earlier:
`power-flows` re-vendored at 0.3, spec re-pinned to 7ee7ca9, peer re-pinned to
panelbench e757910, and the stale device pins corrected to the versions the
specification has carried since before we pinned (0.14 / 0.4 / 0.15). 898 green
with both peer checkouts configured.
… not

b8 taught the schema fetch to treat a 502 as "not ready yet". It shipped with
the old five attempts capped at eight seconds -- roughly twenty-three seconds
against a panel observed taking four minutes to return, still answering 502 when
the broker came back. So the fix was half a fix: the exception was caught and the
loop gave up before the panel could answer.

The widening was written in the same change and lost. Its edit script asserted on
a second substitution, the assert failed, the file was never written, and the
re-do covered only the except clause. Nothing failed afterwards, because catching
the 502 and then giving up early looks exactly like working -- the suite passed,
the release went out, and the published wheel reported `attempts: 5, max backoff:
8.0` when a post-publish check read it back out of PyPI.

Twelve attempts backing off to thirty seconds is 241 seconds, a little over the
observed four-minute reboot.

Pinned by a test that sums the window and compares it against that reboot rather
than asserting the constants individually. A test on the constants is the only
thing that would have caught this, and there was none -- the three of them only
mean anything together, which is also why the assertion is on the total.
Adversarial review of b8/b9 found the fix aimed at what was observed rather than
at what the observation was an instance of. Four more mid-boot answers had the
identical "gives up forever" character, each verified empirically against httpx
0.28.1 rather than reasoned about:

* `httpx.ReadError` and `httpx.WriteError` -- a panel resetting its listener
  mid-request;
* `httpx.RemoteProtocolError`, "server closed connection without sending a
  response" -- exactly what a proxy restarting under load produces;
* a `200` whose body is truncated, empty, or not an object -- a panel part-way
  through starting, answering with a success status and nothing usable.

All four escaped `get_homie_schema` untranslated, skipped the caller's retry
clause, and stranded the parser. `ConnectError` alone was never the right catch:
transport failures are now `SpanPanelConnectionError` via `httpx.TransportError`
-- with the timeout branch kept ahead of it, since `TimeoutException` is one --
and an unusable body is `SpanPanelServerError`, retryable for the same reason a
502 is.

**The window ended in a sleep no attempt followed.** Attempts landed at 0, 1, 3,
7, 15, 31, 61, 91, 121, 151, 181, 211 and the function returned at 241. So the
last request went out at 211s against a reboot sized at 240, and a panel ready at
220 was still abandoned -- and abandonment is sticky, because the triggers are
the reconnect edge and the retained message and a panel that finishes booting
produces neither again. The loop no longer sleeps after its final attempt, which
also stops it holding `_redispatch_in_flight` and the warning for a pointless
backoff, and a thirteenth attempt puts the last request at 241s.

**The test for that window asserted the wrong quantity.** It summed every sleep,
including the dead trailing one, and so restated the implementation's arithmetic
with its off-by-one intact -- 241 >= 240, green, while the last GET was at 211.
It now asserts the offset of the final attempt, which is the property a user
actually gets, and a second test pins that there are N-1 sleeps for N attempts.

The give-up warning promised recovery "until the next reconnect", which cannot
arrive for the reason above; it now says a reload is needed. The
`SpanPanelServerError` docstring said "Server error (500)" and now describes what
it means.
The bound was wrong twice for the same reason: sized against a reboot somebody
had measured, and the next reboot was not that reboot. There is no third
measurement that fixes that, because the quantity is not ours to know.

Giving up has nothing on the other side of the scale. The triggers for another
attempt are the reconnect edge and the retained data-model-version message, and a
panel that finishes booting after the loop expired produces neither -- so
exhausting a bound does not mean "try again later", it means stranded until a
human reloads. And waiting costs no freshness: energy sensors hold their last
reading through an outage on their own grace period, fifteen minutes by default,
which exists precisely so a gap does not become an `unknown` and a statistics
spike. That mechanism is untouched by how long this waits, and it was the only
thing that would have justified a deadline here. What remains is one GET every
thirty seconds to a device on the local network.

The interval settles rather than growing -- 1, 2, 4, 8, 16, 30 and then 30 --
because backing off without a ceiling would mean a panel that took a while to
return was then ignored for longer than it took. Worst case between the panel
answering and this noticing is one interval, however long the wait has run.

Unbounded is only safe because cancellation is prompt, so that is now tested
rather than assumed: `close()` cancels the task and the cancellation lands inside
the sleep.

Logging is first-then-occasional. A panel that never returns would otherwise
write a line every thirty seconds forever, and the second line is worth no more
than the first.

Two mutations verified. Re-bounding the loop fails. Removing the ceiling from the
backoff also fails now -- it did not before, because the test computed the
sequence itself and asserted on its own arithmetic, which is the same fault as
the window test it replaced. It observes the sleeps the real function performs.
Observed on a live install: `Grid` on flat all night, `Unknown` from the moment
it upgraded, with nothing about the site having changed. The field's source moved
-- flat published a closed enum of source classes on the panel, v1.0 names the
forming device on the MID's `grid` node -- and a panel with no battery has no MID,
so the property has no publisher and the field went `None`.

A missing MID settles the answer by elimination rather than leaving it open.
`BATTERY` needs a BESS and a BESS brings a MID. `PV` cannot form a grid alone,
because anything that can is a grid-forming inverter and therefore a MID. `NONE`
describes a panel supplying nothing, which is a panel that is not publishing.
What remains is a generator, and that is two cases of which only one reaches
here: one wired through a MID is named by that MID and answered above, while one
with no MID interface is what SPAN treats as the grid -- and is the only kind an
install with no MID can have. The elimination therefore keeps holding if
MID-integrated generators arrive, because they bring a MID.

Deliberately not the same rule as `resolve_islanding_state`, which refuses this
shortcut, and the counterexample that defeats it there is what supports it here:
a generator-fed island IS islanded, so inferring on-grid from a missing MID would
be wrong, while its grid-forming entity really is what SPAN calls the grid.
Islanding is a safety fact about separation; this is a class of source.

No worse than flat either, which is the bar: flat could not see an uninterfaced
generator and published GRID regardless, so a panel upgrading keeps the answer it
was already giving instead of losing it to the loss of a property.

A MID that exists and has not answered still reports nothing -- genuinely
unknown, and distinct from there being no islanding authority at all. Both
mutations verified: reverting the no-MID case to `None` fails, and collapsing the
silent-MID case into `GRID` fails.
The schema is vendor-extensible and adoption covered half of it. A device type
nothing here models is adopted with its readings; a new property on the BESS, a
charger, a circuit or the panel became a `DiscoveredMetadata` row -- declaration
only, no value, seen by a maintainer reading a diagnostics attachment and by
nobody else. So a battery vendor adding a field reached the user nowhere.

`SpanPanelSnapshot.extension_properties` carries those properties with their
values. `ExtensionSubject` names which modelled subject each hangs off, keyed by
the same map keys the snapshot already uses, so a consumer resolves the device
with a lookup it performs anyway. The field-level wire-to-snapshot mapping stays
internal: the subject is one value per device and cannot drift, while exporting
the map would freeze this adapter's internals as API.

`ExtensionProperty` is deliberately not a `FieldMetadata`. `partition()` walks
the metadata map, so a type that cannot enter it has no path into a payload that
leaves the machine -- the diagnostics guarantee is structural rather than
remembered, and the discovery rows keep flowing unchanged beside it.

Read-only by construction: `settable` is carried for triage and no set topic
exists to populate. These properties sit on the devices whose curated controls
do real work, and a generic write path beside the EVSE ceiling refusal and the
islanding translation would have neither.

`addressed_rows()` is extracted so discovery and extension emission cannot
disagree about what "unaddressed" means -- a disagreement would surface as an
entity the diagnostics call ignored, or the reverse.

Additive in both directions: the snapshot field defaults empty, so an older
adapter degrades to today's behaviour with no new `SchemaAdapter` member, which
would otherwise fail at discovery for every install whose adapter lags a
release. schema-1 0.1.0b9 declares 3.0.0b12 as its floor for the reverse skew.
0.1.0b9 paired both with `kind="panel"` and no instance key, reasoning that
their curated fields land in the panel snapshot. But a subject is an identity,
and a consumer keys an entity on `(kind, instance_key, node/property)`: two
lugs devices declaring the same vendor property produced one identity for two
readings, so whichever sorted first won and the other was dropped.

Identical firmware on both lugs makes that the expected case rather than a
coincidence -- a vendor extension on one is a vendor extension on both.

Keyed on `info/direction` for the reason `find_lugs` documents: the reference
tree's ids are the simulator's naming, while the direction property is what the
schema defines. A lugs device declaring no direction is left unpaired rather
than keyed on something unstable; its properties stay in discovery, which is
where an unidentifiable device belongs.
First public release of the split: span-panel-api 3.0.0, span-panel-api-schema-0
1.0.0 and span-panel-api-schema-1 1.0.0. Every dependency floor between them now
names a stable version rather than the prerelease it tracked during development
-- a specifier naming a prerelease is pip's own signal that prereleases are
acceptable for that requirement, so the old floors would have left a released
install willing to resolve a future beta of its sibling unasked.

Changelogs carry public versions only. Every beta heading is folded into the
entry for the version it was working towards, described against the last public
release rather than against the beta before it, and a fix that only repaired an
earlier beta is gone entirely -- from the point of view of somebody upgrading
between released versions it never happened. That collapses thirteen bootstrap
betas into one 3.0.0 entry and drops the b8/b9/b10 retry-widening narrative, the
_charge_positive rename and the b1-to-b3 discover_adapters churn, none of which
a reader upgrading from 2.6.4 has any use for. RELEASE.md states the rule so the
next beta does not reintroduce the old shape.

Python floor raised to 3.14, matching the only thing that consumes this. Home
Assistant requires >=3.14.2 from 2026.3, and the SPAN integration requires HA
2026.8 or newer, so every install reaching this code is on 3.14 whatever we
declare. The old >=3.10 was never true in either direction: tests/test_packaging
imports tomllib, stdlib only from 3.11, and 3.10 replaces a Protocol's __init__
with (*args, **kwargs), so SchemaAdapter's declared constructor signature is not
introspectable there and the check that stops two independently-versioned wheels
disagreeing about construction had nothing to read. Floor and CI matrix are now
the same version, which is the only arrangement where a green run proves the
declared range. mypy's python_version follows the floor for the same reason.

Raising the target let ruff apply what it unlocks: asyncio.TimeoutError is the
builtin TimeoutError from 3.11, Generator[T, None, None] is Generator[T] from
3.13, and typing.TypeAlias is superseded by the type keyword. The two converted
aliases are annotation-only under `from __future__ import annotations`, so
nothing resolves them at runtime.

The eBus SDK ceiling is now a tested claim. schema-1 declared >=0.19,<0.24 while
the lock pinned 0.21.0 and 0.23.1 was current -- and the lock does not ship, so
every fresh install resolved the version CI had never run. Upgraded to 0.23.1
(ebus-mqtt-client 0.5.0 with it) and the suite is green, which makes the comment
above the bound true again.

Documentation corrected against what the code actually does. The README still
described HomiePropertyAccumulator, HomieLifecycle and HomieDeviceConsumer as
this package's own layers after they moved to schema-0, listed mqtt/accumulator
and mqtt/homie in a tree where neither exists, claimed three protocols while
showing four, credited a simulation engine removed in 2.3.0, and predated the
adapter errors, the widened SpanPanelServerError and the product_name retirement.
It gains a section on the hot-loading model, which is the thing a consumer most
needs and the thing nothing explained. schema-1's README announced "Status:
incomplete -- this distribution does not yet register a schema_1 adapter", which
has been false since its first beta and would have shipped on the 1.0.0 page.

Also adds span_panel_api_schema_1 to ruff's known-first-party, which the comment
directly above it exists to prevent being missed and did not, and adds schema-1
to the coverage measured in CI, which the pre-commit gate covered and the
uploaded report did not.
@cayossarian
cayossarian merged commit b4beeb8 into main Aug 23, 2026
6 checks passed
@cayossarian
cayossarian deleted the feat/discovery-and-catalog-validation branch August 23, 2026 04:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant