Skip to content

refactor: Phase 0 — isolate flat-schema parsing behind SchemaAdapter - #150

Merged
cayossarian merged 8 commits into
developfrom
feat/schema-adapter-phase-0
Aug 4, 2026
Merged

refactor: Phase 0 — isolate flat-schema parsing behind SchemaAdapter#150
cayossarian merged 8 commits into
developfrom
feat/schema-adapter-phase-0

Conversation

@cayossarian

Copy link
Copy Markdown
Member

Phase 0 of the schema-adapter isolation design, per the Phase 0 plan.

What this is

SPAN is shipping a firmware schema change — flat single-device Homie becomes parent/child. This branch inserts a SchemaAdapter protocol seam so a second parser can be added later without touching the transport layer, and moves all flat-schema knowledge behind _impl/schema_0/.

This is a restructure. Zero public API change, zero behavioural change. The HA integration pins this library and keeps working untouched.

What it claims — and what it does not

The protocol seam is complete. All flat wire-format knowledge — topic construction, property routing, snapshot building, node types, circuit-id normalisation — is behind SchemaZeroAdapter. SpanMqttClient talks only to the protocol.

The packaging seam is NOT done, and is Phase 1. The bootstrap still imports from _impl/schema_0 in three places (mqtt/__init__.py ×2 re-exports, client.py's default adapter_factory), so schema_0 cannot yet ship as a separate distribution. import span_panel_api still eagerly loads it, which means the entry point is currently exercised but not load-bearing. Tracked in Phase 1 follow-ups.

Commits

116da92 SchemaAdapter protocol + SpanPanelAdapterMissingError
7854fb4 relocate flat-schema parsing into _impl/schema_0 (pure move)
d10da42 SchemaZeroAdapter composing accumulator + consumer
89c6267 delegate parsing and topic construction to the protocol
52c3276 entry-point adapter discovery
ea15330 factory dispatch + diagnostics properties
b909e52 public-API guard test
1b9b578 final-review fixes

Evidence

  • 383 tests (from 360 at branch point). mypy --strict, ruff, and pylint clean throughout.
  • Old-vs-new parse equivalence, proven not assumed. The final review replayed an identical 40-message Homie stream through the pre-refactor HomieDeviceConsumer and through SchemaZeroAdapter, in separate processes, and diffed a canonical 1132-line snapshot from each: byte-identical, including all three setter topics and the subscribe wildcard.
  • Public API provably unchanged. src/span_panel_api/__init__.py is not in the branch's changed-file list at all. tests/test_public_api_unchanged.py additionally pins all 44 exported names as a two-way guard (fails on removal and on unreviewed addition).
  • Wheel verified_impl/schema_0 ships, entry_points.txt registers schema_0.
  • Every task passed a two-verdict review (spec + quality); the branch then passed a whole-branch review on a more capable model.

One accepted behavioural delta

set_circuit_relay / set_circuit_priority called before connect() previously built a topic from a format string and then silently no-opped. They now raise SpanPanelConnectionError.

Unavoidable once PROPERTY_SET_TOPIC_FMT moves into the adapter. Unreachable through the public API — create_span_client() always connects before returning — so only direct SpanMqttClient construction can hit it. Post-close() behaviour is preserved. Reviewed and judged a strict improvement: silently discarding a control command was the worse behaviour.

Not verified

The live smoke test did not run — no simulator was reachable. All 383 tests use mocked transports.

Three of its four concerns were closed by other means (wheel build, the snapshot replay above, an import-graph probe across six entry orders). What remains genuinely unexercised is a real-broker reconnect / full-rebuild run: _on_pre_rebuild changed shape (two constructor calls collapsed into one _build_adapter(self._panel_size)) and is covered only by a direct-invocation unit test. AsyncMqttBridge is untouched on this branch and the topic strings are proven identical, so risk is low — but it is worth exercising before the Phase 1 release tag.

Deliberately not included

Per the plan: the set_asserted_islanding_state rename, set_evse_user_max_charge_current, Tier-2/Tier-3 dispatch, recursive child discovery, connection-record handling, MID support, and the package split. Verified absent.

Version stays 2.6.4 — Phase 0 ships as part of the 3.0 cut in Phase 1; bumping here would imply a release that has not happened.

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.
@cayossarian
cayossarian changed the base branch from main to develop August 1, 2026 16:24
@cayossarian

Copy link
Copy Markdown
Member Author

Retargeted from main to develop.

This work is a prototype until the approach is proven end-to-end together with a prototype of the HA integration. Until then, nothing lands on main — in this repo or in the integration repo — so that a hotfix can go to either main at any time without stepping around an unproven restructure, and so the whole experiment can be abandoned cleanly if a better approach emerges.

develop was chosen deliberately over a new branch name: ci.yml already filters push and pull_request on [main, develop], so PRs into any other integration branch would run zero CI. Checks on this PR are green under the new base.

Nothing about the change itself is altered by the retarget — the equivalence evidence in the description still stands.

Merge order from here: phase PRs → develop, with main → develop merged periodically to limit drift; develop → main as a single unit only once the approach is proven.

@cayossarian
cayossarian merged commit a31240d into develop Aug 4, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant