refactor: Phase 0 — isolate flat-schema parsing behind SchemaAdapter - #150
Conversation
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.
|
Retargeted from 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
Nothing about the change itself is altered by the retarget — the equivalence evidence in the description still stands. Merge order from here: phase PRs → |
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
SchemaAdapterprotocol 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.SpanMqttClienttalks only to the protocol.❌ The packaging seam is NOT done, and is Phase 1. The bootstrap still imports from
_impl/schema_0in three places (mqtt/__init__.py×2 re-exports,client.py's defaultadapter_factory), soschema_0cannot yet ship as a separate distribution.import span_panel_apistill eagerly loads it, which means the entry point is currently exercised but not load-bearing. Tracked in Phase 1 follow-ups.Commits
116da92SchemaAdapterprotocol +SpanPanelAdapterMissingError7854fb4_impl/schema_0(pure move)d10da42SchemaZeroAdaptercomposing accumulator + consumer89c626752c3276ea15330b909e521b9b578Evidence
--strict, ruff, and pylint clean throughout.HomieDeviceConsumerand throughSchemaZeroAdapter, in separate processes, and diffed a canonical 1132-line snapshot from each: byte-identical, including all three setter topics and the subscribe wildcard.src/span_panel_api/__init__.pyis not in the branch's changed-file list at all.tests/test_public_api_unchanged.pyadditionally pins all 44 exported names as a two-way guard (fails on removal and on unreviewed addition)._impl/schema_0ships,entry_points.txtregistersschema_0.One accepted behavioural delta
set_circuit_relay/set_circuit_prioritycalled beforeconnect()previously built a topic from a format string and then silently no-opped. They now raiseSpanPanelConnectionError.Unavoidable once
PROPERTY_SET_TOPIC_FMTmoves into the adapter. Unreachable through the public API —create_span_client()always connects before returning — so only directSpanMqttClientconstruction 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_rebuildchanged shape (two constructor calls collapsed into one_build_adapter(self._panel_size)) and is covered only by a direct-invocation unit test.AsyncMqttBridgeis 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_staterename,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 the3.0cut in Phase 1; bumping here would imply a release that has not happened.