Phase 1 — ship schema_0 as its own distribution - #151
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 1 of the schema-adapter isolation work. Targets
develop, notmain— this is prototype work until it is proven end to end against an integration prototype.Plan:
SpanPanel_Docs/span-panel-api/2026-08-03-schema-adapter-phase-1-plan.mdWhat this delivers
Phase 0 proved the transport could delegate all parsing to a
SchemaAdapter. It did not prove the parser could be absent — the bootstrap still imported_impl/schema_0in three places, sospan-panel-apicould not be installed without its parser. That is the configuration entry-point discovery exists to support, and it now works.The acceptance test is not a unit test.
scripts/verify_adapterless_install.pyruns against a venv holding only the bootstrap wheel and asserts the transport imports, discovery is empty, and building a parser raisesSpanPanelAdapterMissingErrorby name rather thanModuleNotFoundError. It runs in CI. A test in the dev workspace can never observe this failure, because the adapter is always importable there.Verified locally end to end: bootstrap wheel alone → imports and fails by name; add the adapter wheel → discovery, construction and topic generation all resolve.
Commits, in dependency order
The three bootstrap →
_impledges are severed before the code moves. Sever first and each removal is a small reviewable diff against a working tree; move first and every import error is ambiguous between "the split is wrong" and "this edge was never severed."93b206b590c6ffcac27c0data-model-versioninstead of assuming flatfc209c26308acd48aef8aschema_0as its own distribution3ca7835fa6dd8a1a29990Breaking changes
span-panel-apicontains no parser. Flat-schema panels needspan-panel-api-schema-0installed alongside it.HomieLifecycle,HomiePropertyAccumulator,HomieDeviceConsumerare no longer exported. All three are flat-schema-specific, not Homie-convention-level — verified against every known consumer, which references none of them. They live inspan_panel_api_schema_0now.The HA integration imports nine names from
span_panel_api, all of which stay in the bootstrap and none of which move. That is independent evidence the seam was drawn in the right place.Worth reviewer attention
SchemaAdapter.__init__is now declared on the protocol. Construction was always part of the contract — the transport resolves a class from the registry and calls it — but Phase 0'sCallable[[str, int], SchemaAdapter]alias left the signature unchecked against implementations. mypy caught this the moment the seam became atype[]. The signature carriespanel_size, a flat-schema concept, and is the part of the protocol expected to change withschema_1; stating it makes that a visible protocol break rather than a runtimeTypeError.1,1.0-beta) dispatches on that major and logs. Only a value with no extractable major raises. Previously all of these fell through to the flat parser, which does not fail — it produces plausible but wrong power and energy figures in HA, which is strictly worse than an error.wrong-import-orderis disabled. pylint offersknown-standard-libraryandknown-third-partybut noknown-first-party, so it cannot be told the repo now has two source roots and disagreed with ruff on every adapter module. ruff's isort enforces the same rule and can be told, so it becomes the single authority.3ca7835) is not cosmetic. The local hook passed an explicit--cov=src/span_panel_api, so moving the adapter out silently dropped 543 statements from the report — coverage read 91.6% while the entire flat parser went unmeasured.Checks
426 tests passing, all pre-commit hooks green (mypy strict, ruff, pylint, bandit, vulture), coverage 94%.