From 93b206b484473e4c12f2e8cbf6ede0ff09b6d7ce Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:00:28 -0700 Subject: [PATCH 1/9] feat!: remove the three flat-schema names from the public API 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. --- src/span_panel_api/__init__.py | 4 +--- src/span_panel_api/mqtt/__init__.py | 10 ++++------ tests/test_public_api_unchanged.py | 14 +++++++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index 62ab74f..fb8af21 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -40,7 +40,7 @@ V2HomieSchema, V2StatusInfo, ) -from .mqtt import HomieLifecycle, HomiePropertyAccumulator, MqttClientConfig, SpanMqttClient +from .mqtt import MqttClientConfig, SpanMqttClient from .phase_validation import ( PhaseDistribution, are_tabs_opposite_phase, @@ -93,8 +93,6 @@ "regenerate_passphrase", "register_v2", # Transport - "HomieLifecycle", - "HomiePropertyAccumulator", "MqttClientConfig", "SpanMqttClient", # Phase validation diff --git a/src/span_panel_api/mqtt/__init__.py b/src/span_panel_api/mqtt/__init__.py index 8be5e51..9580610 100644 --- a/src/span_panel_api/mqtt/__init__.py +++ b/src/span_panel_api/mqtt/__init__.py @@ -1,7 +1,8 @@ -"""SPAN Panel MQTT/Homie transport.""" +"""SPAN Panel MQTT/Homie transport. -from span_panel_api._impl.schema_0.accumulator import HomieLifecycle, HomiePropertyAccumulator -from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer +Schema-agnostic: nothing here imports a parsing implementation. The flat-schema +parser is reached only through the `span_panel_api.schema_adapters` entry point. +""" from .async_client import AsyncMQTTClient from .client import SpanMqttClient @@ -11,9 +12,6 @@ __all__ = [ "AsyncMQTTClient", "AsyncMqttBridge", - "HomieDeviceConsumer", - "HomieLifecycle", - "HomiePropertyAccumulator", "MqttClientConfig", "SpanMqttClient", ] diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index f228cc2..19503d7 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -1,7 +1,13 @@ -"""Guard: Phase 0 is a restructure, so the public surface must not move. +"""Guard: the public surface only moves on purpose. -The HA integration pins span-panel-api and imports these names directly. If this -test fails, the change is no longer Phase 0 — it is a breaking release. +The HA integration pins span-panel-api and imports these names directly, so a +failure here means either an accidental break or a deliberate one whose record +belongs in the same commit. The set below is a two-way pin — it fails on both +removals and additions — and editing it is how a break gets acknowledged. + +Phase 0 held it fixed. Phase 1 deliberately breaks it (3.0): the three +flat-schema names below were removed, because the bootstrap can no longer import +a parsing implementation to re-export. """ from __future__ import annotations @@ -45,8 +51,6 @@ "regenerate_passphrase", "register_v2", # Transport - "HomieLifecycle", - "HomiePropertyAccumulator", "MqttClientConfig", "SpanMqttClient", # Phase validation From 590c6ff4e19a77c93a0c338a687bfc8f3b4d32dd Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:05:52 -0700 Subject: [PATCH 2/9] refactor(mqtt): resolve the default adapter through discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/span_panel_api/adapters.py | 22 +++++++++ src/span_panel_api/factory.py | 16 ++---- src/span_panel_api/mqtt/client.py | 21 ++++++-- src/span_panel_api/protocol.py | 16 ++++++ tests/test_adapters_discovery.py | 73 +++++++++++++++++++++++++++- tests/test_factory_dispatch.py | 4 +- tests/test_mqtt_client_connection.py | 14 +++++- tests/test_protocol_conformance.py | 20 ++++++++ 8 files changed, 164 insertions(+), 22 deletions(-) diff --git a/src/span_panel_api/adapters.py b/src/span_panel_api/adapters.py index 173992a..4f0c70b 100644 --- a/src/span_panel_api/adapters.py +++ b/src/span_panel_api/adapters.py @@ -10,6 +10,8 @@ import logging from typing import TYPE_CHECKING +from span_panel_api.exceptions import SpanPanelAdapterMissingError + if TYPE_CHECKING: from span_panel_api.protocol import SchemaAdapter @@ -17,6 +19,12 @@ _ENTRY_POINT_GROUP = "span_panel_api.schema_adapters" _REGISTRY: dict[str, type[SchemaAdapter]] | None = None +# The adapter key for panels that publish no data-model-version. This is a +# bootstrap-level fact — Tier 1 dispatch reads absence as "flat schema" — not an +# import of the flat adapter. The bootstrap knows the *name*; whether anything +# answers to it is entry-point discovery's problem. +DEFAULT_ADAPTER_KEY = "schema_0" + def discover_adapters() -> dict[str, type[SchemaAdapter]]: """Load and cache every adapter class registered under the entry-point group.""" @@ -35,6 +43,20 @@ def discover_adapters() -> dict[str, type[SchemaAdapter]]: return _REGISTRY +def resolve_adapter(key: str, reason: str) -> type[SchemaAdapter]: + """Return the discovered adapter class for `key`, or raise naming what is installed. + + The one place a missing adapter turns into a named error. Both the factory's + Tier 1 dispatch and the transport's default path go through here so a user + whose panel outruns their install sees the same message either way. + """ + registry = discover_adapters() + adapter_cls = registry.get(key) + if adapter_cls is None: + raise SpanPanelAdapterMissingError(needed=key, reason=reason, available=sorted(registry)) + return adapter_cls + + def _reset_adapter_cache() -> None: """Test hook. Not public API.""" global _REGISTRY # pylint: disable=global-statement # test hook for the cache above diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py index f8006e2..7c2f9aa 100644 --- a/src/span_panel_api/factory.py +++ b/src/span_panel_api/factory.py @@ -9,13 +9,12 @@ import logging import re -from .adapters import discover_adapters +from .adapters import resolve_adapter from .auth import register_v2 from .detection import detect_api_version -from .exceptions import SpanPanelAdapterMissingError, SpanPanelAuthError +from .exceptions import SpanPanelAuthError from .mqtt.client import SpanMqttClient from .mqtt.models import MqttClientConfig -from .protocol import SchemaAdapter _LOGGER = logging.getLogger(__name__) @@ -44,15 +43,6 @@ def _select_adapter_key(data_model_version: str | None) -> tuple[str, str]: ) -def _resolve_adapter_cls(key: str, reason: str) -> type[SchemaAdapter]: - """Look up the discovered adapter class for `key`, or raise with the installed list.""" - registry = discover_adapters() - adapter_cls = registry.get(key) - if adapter_cls is None: - raise SpanPanelAdapterMissingError(needed=key, reason=reason, available=sorted(registry)) - return adapter_cls - - async def create_span_client( host: str, passphrase: str | None = None, @@ -107,7 +97,7 @@ async def create_span_client( # every panel currently in the field — Phase 1 adds the fetch. data_model_version: str | None = None adapter_key, dispatch_reason = _select_adapter_key(data_model_version) - adapter_cls = _resolve_adapter_cls(adapter_key, dispatch_reason) + adapter_cls = resolve_adapter(adapter_key, dispatch_reason) client = SpanMqttClient(host, serial_number, mqtt_config, panel_http_port=port, adapter_factory=adapter_cls) client._data_model_version = data_model_version # pylint: disable=protected-access diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 72132ba..142cc2d 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -14,10 +14,9 @@ import logging import time -from span_panel_api._impl.schema_0 import SchemaZeroAdapter from span_panel_api.schema_drift import log_schema_drift -from ..adapters import discover_adapters +from ..adapters import DEFAULT_ADAPTER_KEY, discover_adapters, resolve_adapter from ..auth import get_homie_schema from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot @@ -44,7 +43,7 @@ def __init__( broker_config: MqttClientConfig, snapshot_interval: float = 1.0, panel_http_port: int = 80, - adapter_factory: Callable[[str, int], SchemaAdapter] = SchemaZeroAdapter, + adapter_factory: Callable[[str, int], SchemaAdapter] | None = None, ) -> None: self._host = host self._serial_number = serial_number @@ -80,8 +79,22 @@ def _build_adapter(self, panel_size: int) -> SchemaAdapter: Called from connect() and from the reconnect path — the only two places a parser is built today. + + Resolving the default here rather than in ``__init__`` is deliberate: + constructing a client must not require an adapter to be installed, only + building a parser must. That keeps ``import span_panel_api.mqtt.client`` + working in an adapter-less install — the configuration entry-point + discovery exists to support — and puts the failure at the point where it + is actionable. + + Raises: + SpanPanelAdapterMissingError: No adapter_factory was supplied and no + package registers the default adapter key. """ - self._adapter = self._adapter_factory(self._serial_number, panel_size) + factory = self._adapter_factory + if factory is None: + factory = resolve_adapter(DEFAULT_ADAPTER_KEY, "no adapter_factory supplied to SpanMqttClient") + self._adapter = factory(self._serial_number, panel_size) return self._adapter @property diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index 9675293..2faea5b 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -93,6 +93,22 @@ class SchemaAdapter(Protocol): schema_major: str SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] + def __init__(self, serial_number: str, panel_size: int) -> None: + """Construct a parser for one panel session. + + Declared because construction is part of the contract: the transport + resolves an adapter *class* from the entry-point registry and calls it. + Phase 0 typed the seam as ``Callable[[str, int], SchemaAdapter]``, which + left the signature unchecked against implementations; stating it here + puts it back under the type checker. + + ``panel_size`` is a flat-schema concept the transport fetches on the + adapter's behalf, so this signature is the one part of the protocol + expected to change when schema_1 lands — see the Phase 1 follow-ups, + item 2. It is stated rather than hidden precisely so that change is a + visible protocol break rather than a silent runtime TypeError. + """ + def topics_to_subscribe(self) -> list[str]: ... def handle_message(self, topic: str, payload: str) -> None: ... diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py index 3ad0433..8d574ae 100644 --- a/tests/test_adapters_discovery.py +++ b/tests/test_adapters_discovery.py @@ -1,6 +1,13 @@ from __future__ import annotations -from span_panel_api.adapters import _reset_adapter_cache, discover_adapters +from unittest.mock import patch + +import pytest + +from span_panel_api.adapters import DEFAULT_ADAPTER_KEY, _reset_adapter_cache, discover_adapters, resolve_adapter +from span_panel_api.exceptions import SpanPanelAdapterMissingError +from span_panel_api.mqtt.client import SpanMqttClient +from span_panel_api.mqtt.models import MqttClientConfig def test_discovers_the_self_registered_schema_zero_adapter() -> None: @@ -14,3 +21,67 @@ def test_discovers_the_self_registered_schema_zero_adapter() -> None: def test_registry_is_cached_across_calls() -> None: _reset_adapter_cache() assert discover_adapters() is discover_adapters() + + +# --------------------------------------------------------------------------- +# The default adapter path — the bootstrap must not import a parser to get one +# --------------------------------------------------------------------------- + + +def _client(adapter_factory: object = None) -> SpanMqttClient: + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + kwargs = {} if adapter_factory is None else {"adapter_factory": adapter_factory} + return SpanMqttClient("panel.local", "SERIAL123", config, **kwargs) # type: ignore[arg-type] + + +def test_default_factory_resolves_the_flat_adapter_through_discovery() -> None: + """No adapter_factory means "resolve the default key", not "import SchemaZeroAdapter".""" + _reset_adapter_cache() + client = _client() + + adapter = client._build_adapter(32) + + assert adapter.schema_major == DEFAULT_ADAPTER_KEY + assert type(adapter) is discover_adapters()[DEFAULT_ADAPTER_KEY] + + +def test_constructing_a_client_does_not_require_an_installed_adapter() -> None: + """Construction must stay adapter-free; only building a parser needs one. + + This is the property that lets the bootstrap ship without a parser at all. + """ + with patch("span_panel_api.adapters._REGISTRY", {}): + _client() # must not raise + + +def test_building_a_parser_without_any_adapter_raises_by_name() -> None: + """The adapter-less install's failure mode: a named error, not ModuleNotFoundError.""" + _reset_adapter_cache() + client = _client() + + with patch("span_panel_api.adapters._REGISTRY", {}), pytest.raises(SpanPanelAdapterMissingError) as exc: + client._build_adapter(32) + + assert exc.value.needed == DEFAULT_ADAPTER_KEY + assert exc.value.available == [] + + +def test_an_explicit_factory_bypasses_discovery_entirely() -> None: + """Injection still wins — used by the factory's Tier 1 dispatch and by tests.""" + _reset_adapter_cache() + real_cls = discover_adapters()[DEFAULT_ADAPTER_KEY] + client = _client(adapter_factory=real_cls) + + with patch("span_panel_api.adapters.discover_adapters", side_effect=AssertionError("must not be consulted")): + adapter = client._build_adapter(32) + + assert type(adapter) is real_cls + + +def test_resolve_adapter_names_what_is_installed() -> None: + _reset_adapter_cache() + with pytest.raises(SpanPanelAdapterMissingError) as exc: + resolve_adapter("schema_9", "made-up key") + + assert exc.value.needed == "schema_9" + assert DEFAULT_ADAPTER_KEY in exc.value.available diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py index d6c4456..05872a0 100644 --- a/tests/test_factory_dispatch.py +++ b/tests/test_factory_dispatch.py @@ -29,11 +29,11 @@ def test_present_data_model_version_requests_a_numbered_adapter(dmv: str) -> Non def test_missing_adapter_raises_with_the_installed_list() -> None: - from span_panel_api.factory import _resolve_adapter_cls + from span_panel_api.adapters import resolve_adapter _reset_adapter_cache() with pytest.raises(SpanPanelAdapterMissingError) as exc: - _resolve_adapter_cls("schema_1", "data-model-version='1.0'") + resolve_adapter("schema_1", "data-model-version='1.0'") assert exc.value.needed == "schema_1" assert "schema_0" in exc.value.available diff --git a/tests/test_mqtt_client_connection.py b/tests/test_mqtt_client_connection.py index a7742fc..b35f0d4 100644 --- a/tests/test_mqtt_client_connection.py +++ b/tests/test_mqtt_client_connection.py @@ -448,7 +448,16 @@ def test_adapter_is_none_before_connect() -> None: assert client.adapter is None -def test_client_defaults_to_the_schema_zero_factory() -> None: +def test_client_defaults_to_the_flat_adapter() -> None: + """Unchanged behaviour, different mechanism. + + Phase 0 pinned the default as an identity check against an imported + SchemaZeroAdapter. Phase 1 resolves it through entry-point discovery + instead, so the default is deliberately *unset* at construction and only + materialises when a parser is built. Asserting the built adapter rather + than the stored factory keeps the guarantee that mattered — a directly + constructed client still parses the flat schema. + """ from span_panel_api._impl.schema_0 import SchemaZeroAdapter from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig @@ -457,7 +466,8 @@ def test_client_defaults_to_the_schema_zero_factory() -> None: "192.0.2.10", "sim-40t-001", MqttClientConfig(broker_host="192.0.2.10", username="test", password="test") ) - assert client._adapter_factory is SchemaZeroAdapter + assert client._adapter_factory is None + assert isinstance(client._build_adapter(40), SchemaZeroAdapter) def test_injected_factory_receives_serial_and_panel_size() -> None: diff --git a/tests/test_protocol_conformance.py b/tests/test_protocol_conformance.py index dc7eb0e..50101fc 100644 --- a/tests/test_protocol_conformance.py +++ b/tests/test_protocol_conformance.py @@ -81,6 +81,26 @@ def test_schema_adapter_declares_its_class_attributes() -> None: assert name in SchemaAdapter.__annotations__, f"SchemaAdapter is missing attribute {name}" +def test_schema_adapter_construction_signature_matches_its_implementation() -> None: + """Construction is part of the contract, so it must be checked like the rest. + + `hasattr(SchemaAdapter, "__init__")` is vacuous — every object has one. The + assertion with teeth is that the protocol's declared signature and the + installed adapter's actual signature agree, which is what the transport + depends on when it calls a class resolved from the entry-point registry. + """ + import inspect + + from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api.protocol import SchemaAdapter + + declared = list(inspect.signature(SchemaAdapter.__init__).parameters) + implemented = list(inspect.signature(SchemaZeroAdapter.__init__).parameters) + + assert declared == ["self", "serial_number", "panel_size"] + assert implemented == declared, f"SchemaZeroAdapter.__init__{implemented} does not match the protocol {declared}" + + def test_adapter_missing_error_reports_what_is_installed() -> None: from span_panel_api.exceptions import SpanPanelAdapterMissingError From cac27c09c64516a05cdf4d7e5cb4074fae7fa82f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:08:19 -0700 Subject: [PATCH 3/9] fix(factory): refuse an unreadable data-model-version instead of assuming flat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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. --- src/span_panel_api/exceptions.py | 23 +++++++++++++++ src/span_panel_api/factory.py | 50 ++++++++++++++++++++++++++------ tests/test_factory_dispatch.py | 38 ++++++++++++++++++++++-- 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/src/span_panel_api/exceptions.py b/src/span_panel_api/exceptions.py index 16323a0..765966c 100644 --- a/src/span_panel_api/exceptions.py +++ b/src/span_panel_api/exceptions.py @@ -42,6 +42,29 @@ class SpanPanelStaleDataError(SpanPanelError): """ +class SpanPanelSchemaVersionError(SpanPanelError): + """The panel reports a data-model-version this library cannot interpret. + + Distinct from SpanPanelAdapterMissingError, because the remedy differs. A + missing adapter is a known schema with no installed parser — install or + update the adapter package. This is a schema whose *major cannot even be + determined*, so no adapter can be named. That is a panel this library has + never seen, and the honest response is to say so. + + Absence is not this error: a panel that publishes no data-model-version at + all is speaking the flat schema, which is a real and supported signal. + """ + + def __init__(self, data_model_version: str) -> None: + self.data_model_version = data_model_version + super().__init__( + f"Cannot determine a schema major from data-model-version {data_model_version!r}. " + "Expected MAJOR.MINOR[.PATCH]. Refusing to guess — parsing this panel with the " + "wrong schema would produce plausible but incorrect power and energy values. " + "Please report this value." + ) + + class SpanPanelAdapterMissingError(SpanPanelError): """No installed adapter covers the schema this panel publishes.""" diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py index 7c2f9aa..903f95e 100644 --- a/src/span_panel_api/factory.py +++ b/src/span_panel_api/factory.py @@ -12,7 +12,7 @@ from .adapters import resolve_adapter from .auth import register_v2 from .detection import detect_api_version -from .exceptions import SpanPanelAuthError +from .exceptions import SpanPanelAuthError, SpanPanelSchemaVersionError from .mqtt.client import SpanMqttClient from .mqtt.models import MqttClientConfig @@ -20,7 +20,11 @@ _V2_CLIENT_NAME = "span-panel-api" -_DMV_PATTERN = re.compile(r"^(\d+)\.\d+(?:\.\d+)?$") +# The canonical form the published spec defines: MAJOR.MINOR[.PATCH]. +_DMV_CANONICAL = re.compile(r"^(\d+)\.\d+(?:\.\d+)?$") +# Tolerant form: a leading integer major, optionally followed by a separator and +# anything at all. Accepts '1', '1.0.3-rc2', '1_0'; rejects 'v1.0', '', 'x'. +_DMV_MAJOR = re.compile(r"^(\d+)(?:[._-].*)?$") def _select_adapter_key(data_model_version: str | None) -> tuple[str, str]: @@ -29,18 +33,42 @@ def _select_adapter_key(data_model_version: str | None) -> tuple[str, str]: Absence is the flat-schema signal — the property was introduced by the same firmware that introduced the parent/child model, so a panel that does not publish it is speaking the flat schema. + + Presence is never read as flat. Falling back to schema_0 for a value we do + not recognise would hand a parent/child panel to the flat parser, which does + not fail — it produces plausible but wrong power and energy figures. A wrong + number in Home Assistant is worse than an error, so anything present and + unreadable raises instead. + + Between those two poles sits a value whose major is unambiguous even though + its full form is not canonical ('1', '1.0-beta'). That is not a guess: the + major is what selects the adapter, and it was read, not assumed. Those + dispatch normally and log the deviation, so a firmware that starts emitting + a new format is visible before it is an outage. + + Raises: + SpanPanelSchemaVersionError: A version is present but no major can be + extracted from it. """ if data_model_version is None: return "schema_0", "data-model-version absent (flat schema)" - match = _DMV_PATTERN.match(data_model_version) - if match is None: - return "schema_0", f"unrecognised data-model-version={data_model_version!r}, assuming flat" + if (match := _DMV_CANONICAL.match(data_model_version)) is not None: + return f"schema_{int(match.group(1))}", f"data-model-version={data_model_version!r}" + + if (match := _DMV_MAJOR.match(data_model_version)) is not None: + _LOGGER.warning( + "data-model-version=%r is not the canonical MAJOR.MINOR[.PATCH] form; " + "dispatching on major %s. Please report this value.", + data_model_version, + match.group(1), + ) + return ( + f"schema_{int(match.group(1))}", + f"data-model-version={data_model_version!r} (non-canonical; major only)", + ) - return ( - f"schema_{int(match.group(1))}", - f"data-model-version={data_model_version!r}", - ) + raise SpanPanelSchemaVersionError(data_model_version) async def create_span_client( @@ -67,6 +95,10 @@ async def create_span_client( or serial_number could not be determined. SpanPanelConnectionError: Cannot reach panel during detection or registration. SpanPanelTimeoutError: Timeout during detection or registration. + SpanPanelSchemaVersionError: The panel reports a data-model-version whose + schema major cannot be determined. + SpanPanelAdapterMissingError: No installed package provides an adapter for + the schema major this panel reports. """ if mqtt_config is None: if passphrase is None: diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py index 05872a0..2a49778 100644 --- a/tests/test_factory_dispatch.py +++ b/tests/test_factory_dispatch.py @@ -7,7 +7,7 @@ from span_panel_api._impl.schema_0 import SchemaZeroAdapter from span_panel_api.adapters import _reset_adapter_cache -from span_panel_api.exceptions import SpanPanelAdapterMissingError +from span_panel_api.exceptions import SpanPanelAdapterMissingError, SpanPanelSchemaVersionError from span_panel_api.factory import _select_adapter_key from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig @@ -21,13 +21,47 @@ def test_absent_data_model_version_selects_schema_zero() -> None: assert "absent" in reason -@pytest.mark.parametrize("dmv", ["1.0", "1.4", "2.0"]) +@pytest.mark.parametrize("dmv", ["1.0", "1.4", "2.0", "1.0.3", "10.2"]) def test_present_data_model_version_requests_a_numbered_adapter(dmv: str) -> None: key, reason = _select_adapter_key(dmv) assert key == f"schema_{dmv.split('.')[0]}" assert dmv in reason +@pytest.mark.parametrize("dmv", ["1", "1.0-beta", "1.0.3-rc2", "2_0"]) +def test_non_canonical_but_unambiguous_versions_dispatch_on_their_major(dmv: str) -> None: + """The major was read, not assumed, so dispatching on it is not a guess. + + Refusing these would take a panel offline over a formatting difference; the + deviation is logged instead so a new firmware format is visible early. + """ + key, reason = _select_adapter_key(dmv) + assert key == f"schema_{dmv[0]}" + assert "non-canonical" in reason + + +@pytest.mark.parametrize("dmv", ["", "v1.0", "unknown", "beta", "-1", " 1.0"]) +def test_unreadable_data_model_version_raises_instead_of_assuming_flat(dmv: str) -> None: + """The regression this guards: a present-but-unreadable version must never + reach the flat parser. + + Falling back to schema_0 does not fail — it silently produces plausible but + wrong power and energy values in Home Assistant, which is strictly worse + than an error the user can see and report. + """ + with pytest.raises(SpanPanelSchemaVersionError) as exc: + _select_adapter_key(dmv) + + assert exc.value.data_model_version == dmv + + +def test_absence_is_still_a_supported_signal_not_an_error() -> None: + """The flat schema predates the property, so absence must stay non-fatal — + it is the single most common case in the field today.""" + key, _ = _select_adapter_key(None) + assert key == "schema_0" + + def test_missing_adapter_raises_with_the_installed_list() -> None: from span_panel_api.adapters import resolve_adapter From fc209c2bb3ed2400ca305333e5c6788c7ce52b78 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:10:57 -0700 Subject: [PATCH 4/9] feat(adapters): validate entry points before registering them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/span_panel_api/adapters.py | 55 +++++++++++++++++--- tests/test_adapters_discovery.py | 89 ++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/span_panel_api/adapters.py b/src/span_panel_api/adapters.py index 4f0c70b..d4f92e9 100644 --- a/src/span_panel_api/adapters.py +++ b/src/span_panel_api/adapters.py @@ -8,17 +8,26 @@ from importlib.metadata import entry_points import logging -from typing import TYPE_CHECKING +from typing import TypeGuard from span_panel_api.exceptions import SpanPanelAdapterMissingError - -if TYPE_CHECKING: - from span_panel_api.protocol import SchemaAdapter +from span_panel_api.protocol import SchemaAdapter _LOGGER = logging.getLogger(__name__) _ENTRY_POINT_GROUP = "span_panel_api.schema_adapters" _REGISTRY: dict[str, type[SchemaAdapter]] | None = None +# Derived from the protocol rather than restated, so the check cannot drift out +# of sync with the contract it enforces — adding a method to SchemaAdapter +# automatically makes it required of every adapter package. +# +# `issubclass` is not available here: SchemaAdapter has non-method members, and +# runtime_checkable protocols with data attributes reject issubclass() outright. +_REQUIRED_MEMBERS: tuple[str, ...] = ( + *sorted(SchemaAdapter.__annotations__), + *sorted(name for name, value in vars(SchemaAdapter).items() if callable(value) and not name.startswith("_")), +) + # The adapter key for panels that publish no data-model-version. This is a # bootstrap-level fact — Tier 1 dispatch reads absence as "flat schema" — not an # import of the flat adapter. The bootstrap knows the *name*; whether anything @@ -26,8 +35,37 @@ DEFAULT_ADAPTER_KEY = "schema_0" +def _is_adapter_class(loaded: object) -> TypeGuard[type[SchemaAdapter]]: + """Narrow an entry point's loaded object to an adapter class. + + A TypeGuard rather than a bare bool: `ep.load()` returns `Any`, and this is + the boundary where that `Any` has to become a checked `type[SchemaAdapter]` + rather than being assigned into the registry unexamined. + + Deliberately checks member *presence* only. A Protocol cannot express + signatures at runtime, so an adapter with the right names and the wrong + arity still gets through and fails at call time. The check is worth having + anyway: it catches the failure that actually happens — a module, function or + instance registered where a class belongs — and turns it into a named, + logged skip instead of an opaque TypeError deep inside connect(). + """ + return isinstance(loaded, type) and all(hasattr(loaded, member) for member in _REQUIRED_MEMBERS) + + +def _describe_defect(loaded: object) -> str: + """Explain why `loaded` failed _is_adapter_class. Only called on the error path.""" + if not isinstance(loaded, type): + return f"expected a class, got {type(loaded).__name__}" + missing = [member for member in _REQUIRED_MEMBERS if not hasattr(loaded, member)] + return f"{loaded.__name__} does not implement SchemaAdapter (missing: {', '.join(missing)})" + + def discover_adapters() -> dict[str, type[SchemaAdapter]]: - """Load and cache every adapter class registered under the entry-point group.""" + """Load and cache every adapter class registered under the entry-point group. + + A bad entry point is skipped with a logged reason, never raised: one broken + third-party adapter must not take down a panel whose own adapter is fine. + """ global _REGISTRY # pylint: disable=global-statement # process-lifetime cache by design if _REGISTRY is None: registry: dict[str, type[SchemaAdapter]] = {} @@ -36,9 +74,14 @@ def discover_adapters() -> dict[str, type[SchemaAdapter]]: _LOGGER.warning("Duplicate schema adapter entry point %r; keeping the first found", ep.name) continue try: - registry[ep.name] = ep.load() + loaded: object = ep.load() except Exception: # pylint: disable=broad-exception-caught _LOGGER.exception("Failed to load schema adapter entry point %r", ep.name) + continue + if not _is_adapter_class(loaded): + _LOGGER.error("Ignoring schema adapter entry point %r: %s", ep.name, _describe_defect(loaded)) + continue + registry[ep.name] = loaded _REGISTRY = registry return _REGISTRY diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py index 8d574ae..627cc4e 100644 --- a/tests/test_adapters_discovery.py +++ b/tests/test_adapters_discovery.py @@ -85,3 +85,92 @@ def test_resolve_adapter_names_what_is_installed() -> None: assert exc.value.needed == "schema_9" assert DEFAULT_ADAPTER_KEY in exc.value.available + + +# --------------------------------------------------------------------------- +# Entry-point validation — a bad adapter package must not become an opaque +# TypeError deep inside connect() +# --------------------------------------------------------------------------- + + +class _FakeEntryPoint: + def __init__(self, name: str, value: object) -> None: + self.name = name + self._value = value + + def load(self) -> object: + return self._value + + +def _discover_with(*eps: _FakeEntryPoint) -> dict[str, object]: + _reset_adapter_cache() + with patch("span_panel_api.adapters.entry_points", return_value=list(eps)): + return dict(discover_adapters()) + + +def test_required_members_are_derived_from_the_protocol() -> None: + """The check must not restate the contract — a method added to SchemaAdapter + becomes required of every adapter without anyone remembering to update a list.""" + from span_panel_api.adapters import _REQUIRED_MEMBERS + from span_panel_api.protocol import SchemaAdapter + + assert set(SchemaAdapter.__annotations__) <= set(_REQUIRED_MEMBERS) + assert "topics_to_subscribe" in _REQUIRED_MEMBERS + assert "build_snapshot" in _REQUIRED_MEMBERS + # Dunders are excluded: presence tells us nothing, every object has them. + assert not [member for member in _REQUIRED_MEMBERS if member.startswith("_")] + + +@pytest.mark.parametrize( + ("label", "value"), + [ + ("a module", pytest), + ("a function", lambda serial, size: None), + ("an instance rather than a class", object()), + ("a string", "span_panel_api_schema_0:SchemaZeroAdapter"), + ], +) +def test_non_class_entry_points_are_skipped_not_registered(label: str, value: object) -> None: + """The failure that actually happens: an entry point pointing at the wrong + kind of object. Phase 0 stored it and blew up later inside connect().""" + assert _discover_with(_FakeEntryPoint("schema_9", value)) == {}, label + + +def test_a_class_missing_protocol_members_is_skipped() -> None: + class NotAnAdapter: + schema_major = "schema_9" + + assert _discover_with(_FakeEntryPoint("schema_9", NotAnAdapter)) == {} + + +def test_a_conforming_class_is_registered() -> None: + from span_panel_api._impl.schema_0 import SchemaZeroAdapter + + registry = _discover_with(_FakeEntryPoint("schema_0", SchemaZeroAdapter)) + + assert registry == {"schema_0": SchemaZeroAdapter} + + +def test_one_bad_adapter_does_not_hide_the_good_ones() -> None: + """A broken third-party adapter must not take down a panel whose own adapter + is installed and fine.""" + from span_panel_api._impl.schema_0 import SchemaZeroAdapter + + registry = _discover_with( + _FakeEntryPoint("schema_9", "not a class"), + _FakeEntryPoint("schema_0", SchemaZeroAdapter), + ) + + assert registry == {"schema_0": SchemaZeroAdapter} + + +def test_an_entry_point_that_raises_on_load_is_skipped() -> None: + from span_panel_api._impl.schema_0 import SchemaZeroAdapter + + class Exploding(_FakeEntryPoint): + def load(self) -> object: + raise ImportError("adapter package is half-installed") + + registry = _discover_with(Exploding("schema_9", None), _FakeEntryPoint("schema_0", SchemaZeroAdapter)) + + assert registry == {"schema_0": SchemaZeroAdapter} From 6308acdd04689912ed4bb35458b333d288cc4d94 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:15:36 -0700 Subject: [PATCH 5/9] chore: clear the Phase 1 small items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/span_panel_api/__init__.py | 4 ++ src/span_panel_api/_impl/schema_0/__init__.py | 8 ++-- src/span_panel_api/_impl/schema_0/const.py | 39 ++++++++++++++----- .../_impl/schema_0/field_metadata.py | 3 +- src/span_panel_api/factory.py | 12 ++++-- src/span_panel_api/mqtt/client.py | 11 ++++-- tests/test_factory_dispatch.py | 9 +++-- tests/test_public_api_unchanged.py | 2 + 8 files changed, 65 insertions(+), 23 deletions(-) diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index fb8af21..04994d0 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -18,10 +18,12 @@ ) from .detection import DetectionResult, detect_api_version from .exceptions import ( + SpanPanelAdapterMissingError, SpanPanelAPIError, SpanPanelAuthError, SpanPanelConnectionError, SpanPanelError, + SpanPanelSchemaVersionError, SpanPanelServerError, SpanPanelStaleDataError, SpanPanelTimeoutError, @@ -104,7 +106,9 @@ "validate_solar_tabs", # Exceptions "SpanPanelAPIError", + "SpanPanelAdapterMissingError", "SpanPanelAuthError", + "SpanPanelSchemaVersionError", "SpanPanelConnectionError", "SpanPanelError", "SpanPanelServerError", diff --git a/src/span_panel_api/_impl/schema_0/__init__.py b/src/span_panel_api/_impl/schema_0/__init__.py index a5314d1..e5c10b5 100644 --- a/src/span_panel_api/_impl/schema_0/__init__.py +++ b/src/span_panel_api/_impl/schema_0/__init__.py @@ -2,8 +2,10 @@ from span_panel_api._impl.schema_0.adapter import SchemaZeroAdapter -# Inclusive lower bound, exclusive upper bound. The flat schema publishes no -# data-model-version, so it is treated as the synthetic version 0 range. -SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=0", "<1.0") +# Re-exported from the adapter rather than restated. The protocol requires the +# range as a class attribute, so the class is the source of truth; a second +# literal here would be free to drift, and nothing would notice until a panel +# reported a version this adapter claims — falsely — to support. +SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = SchemaZeroAdapter.SUPPORTS_DATA_MODEL_VERSIONS __all__ = ["SUPPORTS_DATA_MODEL_VERSIONS", "SchemaZeroAdapter"] diff --git a/src/span_panel_api/_impl/schema_0/const.py b/src/span_panel_api/_impl/schema_0/const.py index 74b17e5..6e85d5f 100644 --- a/src/span_panel_api/_impl/schema_0/const.py +++ b/src/span_panel_api/_impl/schema_0/const.py @@ -5,26 +5,47 @@ HOMIE_DOMAIN = "ebus" TOPIC_PREFIX = f"{HOMIE_DOMAIN}/{HOMIE_VERSION}" -# Topic patterns (serial_number substituted at runtime) -DEVICE_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}" -STATE_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/$state" -DESCRIPTION_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/$description" -PROPERTY_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}" +# Topic patterns (serial_number substituted at runtime). +# The adapter subscribes with the wildcard and publishes with the set pattern; +# per-topic read formats are not needed because every message arrives through +# the one wildcard subscription. PROPERTY_SET_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}/set" WILDCARD_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/#" -# Homie type strings from schema +# --------------------------------------------------------------------------- +# Homie type strings. +# +# Two namespaces that are easy to conflate and are NOT the same set: +# +# * the `types` block of GET /api/v2/homie/schema, which declares the +# properties, units and datatypes available to a type; and +# * the `type` string a node actually carries in its $description on the wire. +# +# Every constant below is a node type observed on the wire. The ones in the +# first group are also declared in the schema, so metadata lookup finds them +# directly. See tests/test_schema_provenance.py, which asserts that. +# --------------------------------------------------------------------------- TYPE_CORE = "energy.ebus.device.distribution-enclosure.core" TYPE_LUGS = "energy.ebus.device.lugs" -TYPE_LUGS_UPSTREAM = "energy.ebus.device.lugs.upstream" -TYPE_LUGS_DOWNSTREAM = "energy.ebus.device.lugs.downstream" TYPE_CIRCUIT = "energy.ebus.device.circuit" TYPE_BESS = "energy.ebus.device.bess" TYPE_PV = "energy.ebus.device.pv" TYPE_EVSE = "energy.ebus.device.evse" -TYPE_PCS = "energy.ebus.device.pcs" TYPE_POWER_FLOWS = "energy.ebus.device.power-flows" +# Wire-only subtypes: real node types published by real firmware (confirmed +# against a live panel in 1eef0dc), but NOT declared in the schema's `types` +# block, which carries only the base `energy.ebus.device.lugs`. Firmware uses +# one convention or the other — typed nodes, or generic nodes plus a +# `direction` property — and _find_lugs_node handles both. +# +# Because the schema does not declare them, every one of these needs an entry +# in field_metadata._LUGS_FALLBACK mapping it to a declared type, or property +# metadata silently comes back empty for those nodes. The provenance test +# asserts that pairing rather than trusting it. +TYPE_LUGS_UPSTREAM = "energy.ebus.device.lugs.upstream" +TYPE_LUGS_DOWNSTREAM = "energy.ebus.device.lugs.downstream" + # Lugs direction values LUGS_UPSTREAM = "UPSTREAM" LUGS_DOWNSTREAM = "DOWNSTREAM" diff --git a/src/span_panel_api/_impl/schema_0/field_metadata.py b/src/span_panel_api/_impl/schema_0/field_metadata.py index ab83d68..6f5b828 100644 --- a/src/span_panel_api/_impl/schema_0/field_metadata.py +++ b/src/span_panel_api/_impl/schema_0/field_metadata.py @@ -33,7 +33,8 @@ # # This encodes the library's internal knowledge of how _build_snapshot() # maps Homie properties to snapshot dataclass fields. The mapping must be -# kept in sync with homie.py. +# kept in sync with consumer.py (which held this class as homie.py before +# the Phase 0 relocation). # --------------------------------------------------------------------------- _PROPERTY_FIELD_MAP: tuple[tuple[str, str, str], ...] = ( diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py index 903f95e..2f7e125 100644 --- a/src/span_panel_api/factory.py +++ b/src/span_panel_api/factory.py @@ -131,8 +131,14 @@ async def create_span_client( adapter_key, dispatch_reason = _select_adapter_key(data_model_version) adapter_cls = resolve_adapter(adapter_key, dispatch_reason) - client = SpanMqttClient(host, serial_number, mqtt_config, panel_http_port=port, adapter_factory=adapter_cls) - client._data_model_version = data_model_version # pylint: disable=protected-access - client._schema_dispatch_reason = dispatch_reason # pylint: disable=protected-access + client = SpanMqttClient( + host, + serial_number, + mqtt_config, + panel_http_port=port, + adapter_factory=adapter_cls, + data_model_version=data_model_version, + schema_dispatch_reason=dispatch_reason, + ) await client.connect() return client diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 142cc2d..9e418eb 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -44,6 +44,8 @@ def __init__( snapshot_interval: float = 1.0, panel_http_port: int = 80, adapter_factory: Callable[[str, int], SchemaAdapter] | None = None, + data_model_version: str | None = None, + schema_dispatch_reason: str | None = None, ) -> None: self._host = host self._serial_number = serial_number @@ -69,10 +71,11 @@ def __init__( # Homie accumulator with the same panel size after a transport-level # rebuild. Schema cannot change within a session, so caching is safe. self._panel_size: int | None = None - # Diagnostics — the factory overwrites these after adapter selection. - # Defaults describe a client built directly (bypassing create_span_client). - self._data_model_version: str | None = None - self._schema_dispatch_reason: str = "not dispatched" + # Diagnostics, passed in by create_span_client so they are true from the + # first moment the object exists. Constructing directly leaves them + # describing exactly that: a client that never went through dispatch. + self._data_model_version = data_model_version + self._schema_dispatch_reason = schema_dispatch_reason or "not dispatched" def _build_adapter(self, panel_size: int) -> SchemaAdapter: """Construct the parser for this session. diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py index 2a49778..c50fd80 100644 --- a/tests/test_factory_dispatch.py +++ b/tests/test_factory_dispatch.py @@ -102,9 +102,12 @@ async def test_create_span_client_wires_schema_zero_adapter_and_diagnostics() -> _, kwargs = mock_cls.call_args assert kwargs["adapter_factory"] is SchemaZeroAdapter mock_client.connect.assert_awaited_once() - # Diagnostics were assigned directly on the instance ahead of connect(). - assert mock_client._data_model_version is None # pylint: disable=protected-access - assert "absent" in mock_client._schema_dispatch_reason # pylint: disable=protected-access + # Diagnostics travel through the constructor, so they are true before + # connect() rather than patched onto private state afterwards. There is no + # longer a window where a connected client reports a selected adapter next + # to schema_dispatch_reason='not dispatched'. + assert kwargs["data_model_version"] is None + assert "absent" in kwargs["schema_dispatch_reason"] # --------------------------------------------------------------------------- diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index 19503d7..3375f6b 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -62,7 +62,9 @@ "validate_solar_tabs", # Exceptions "SpanPanelAPIError", + "SpanPanelAdapterMissingError", "SpanPanelAuthError", + "SpanPanelSchemaVersionError", "SpanPanelConnectionError", "SpanPanelError", "SpanPanelServerError", From 48aef8a10a3f39f9b2a584009ab9f3d0e91146a2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:28:55 -0700 Subject: [PATCH 6/9] feat!: ship schema_0 as its own distribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 29 +++++-- .pre-commit-config.yaml | 2 +- packages/schema-0/README.md | 32 ++++++++ packages/schema-0/pyproject.toml | 35 +++++++++ .../src/span_panel_api_schema_0}/__init__.py | 2 +- .../span_panel_api_schema_0}/accumulator.py | 2 +- .../src/span_panel_api_schema_0}/adapter.py | 8 +- .../src/span_panel_api_schema_0}/const.py | 0 .../src/span_panel_api_schema_0}/consumer.py | 18 ++--- .../field_metadata.py | 4 +- pyproject.toml | 36 ++++++++- scripts/verify_adapterless_install.py | 77 +++++++++++++++++++ src/span_panel_api/_impl/__init__.py | 1 - src/span_panel_api/schema_drift.py | 2 +- tests/conftest.py | 2 +- tests/test_accumulator.py | 4 +- tests/test_adapters_discovery.py | 6 +- tests/test_auth_and_homie_helpers.py | 4 +- tests/test_factory_dispatch.py | 2 +- tests/test_field_metadata.py | 2 +- tests/test_mqtt_client_connection.py | 6 +- tests/test_mqtt_homie.py | 14 ++-- tests/test_protocol_conformance.py | 2 +- tests/test_schema_zero_adapter.py | 2 +- uv.lock | 25 +++++- 25 files changed, 260 insertions(+), 57 deletions(-) create mode 100644 packages/schema-0/README.md create mode 100644 packages/schema-0/pyproject.toml rename {src/span_panel_api/_impl/schema_0 => packages/schema-0/src/span_panel_api_schema_0}/__init__.py (88%) rename {src/span_panel_api/_impl/schema_0 => packages/schema-0/src/span_panel_api_schema_0}/accumulator.py (99%) rename {src/span_panel_api/_impl/schema_0 => packages/schema-0/src/span_panel_api_schema_0}/adapter.py (88%) rename {src/span_panel_api/_impl/schema_0 => packages/schema-0/src/span_panel_api_schema_0}/const.py (100%) rename {src/span_panel_api/_impl/schema_0 => packages/schema-0/src/span_panel_api_schema_0}/consumer.py (99%) rename {src/span_panel_api/_impl/schema_0 => packages/schema-0/src/span_panel_api_schema_0}/field_metadata.py (99%) create mode 100644 scripts/verify_adapterless_install.py delete mode 100644 src/span_panel_api/_impl/__init__.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69cfd2f..23b0983 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: enable-cache: true - name: Install dependencies - run: uv sync + run: uv sync --all-packages - name: Run pre-commit hooks run: | @@ -36,7 +36,10 @@ jobs: - name: Run tests with pytest run: | - uv run pytest tests/ -v --cov=src/span_panel_api --cov-report=xml --cov-report=term-missing + uv run pytest tests/ -v \ + --cov=src/span_panel_api \ + --cov=packages/schema-0/src/span_panel_api_schema_0 \ + --cov-report=xml --cov-report=term-missing @@ -57,11 +60,11 @@ jobs: enable-cache: true - name: Install dependencies - run: uv sync + run: uv sync --all-packages - name: Run Bandit security scan run: | - uv run bandit -r src/ -f json -o bandit-report.json || true + uv run bandit -r src/ packages/ -f json -o bandit-report.json || true - name: Upload Bandit scan results uses: actions/upload-artifact@v7 @@ -86,14 +89,24 @@ jobs: enable-cache: true - name: Install dependencies - run: uv sync + run: uv sync --all-packages - - name: Build package - run: uv build + - name: Build packages + run: uv build --all-packages - - name: Check package + - name: Check packages run: uv run twine check dist/* + # The configuration entry-point discovery exists to support, and the one + # nothing else in CI exercises: the bootstrap wheel installed with no + # adapter present. It must import, and it must fail by name rather than + # with ModuleNotFoundError. + - name: Verify the bootstrap installs without an adapter + run: | + uv venv /tmp/bootstrap-only + VIRTUAL_ENV=/tmp/bootstrap-only uv pip install dist/span_panel_api-*.whl + VIRTUAL_ENV=/tmp/bootstrap-only uv run --no-project python scripts/verify_adapterless_install.py + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7ae6a9a..2b25c4c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -108,7 +108,7 @@ repos: hooks: - id: vulture name: vulture - entry: bash -c 'uv run vulture src/span_panel_api/ --min-confidence 80' + entry: bash -c 'uv run vulture src/span_panel_api/ packages/schema-0/src/span_panel_api_schema_0/ --min-confidence 80' language: system types: [python] pass_filenames: false diff --git a/packages/schema-0/README.md b/packages/schema-0/README.md new file mode 100644 index 0000000..9a8abc3 --- /dev/null +++ b/packages/schema-0/README.md @@ -0,0 +1,32 @@ +# span-panel-api-schema-0 + +The **flat-schema** parser for [`span-panel-api`](https://github.com/SpanPanel/span-panel-api): the single-device Homie model published by SPAN firmware `r202603` through `r202627`, which carries no `data-model-version`. + +## Why this is a separate distribution + +`span-panel-api` is a transport and a dispatcher. It knows how to connect to a panel's MQTT broker, route messages, and choose a parser — but it contains no parsing code and no Homie type strings. Each wire format ships as its own distribution and +registers itself under the `span_panel_api.schema_adapters` entry-point group. + +That split exists because the two halves break on different axes. The wire format changes when SPAN ships firmware; the library API changes when we do. Separate distributions let each carry its own version, so a consumer can pin them independently and add +support for a new panel schema by installing a package rather than by upgrading the transport. + +## Installation + +```console +pip install span-panel-api span-panel-api-schema-0 +``` + +Installing this package is what makes flat-schema panels work. `span-panel-api` on its own will connect and then raise `SpanPanelAdapterMissingError` naming the adapter it could not find. + +A consumer that wants to support panels on either schema installs both adapters: + +```console +pip install span-panel-api span-panel-api-schema-0 span-panel-api-schema-1 +``` + +Dispatch happens at runtime, per panel, from the `data-model-version` the panel reports. + +## Retirement + +SPAN retires the flat schema in the same release that introduces the parent/child model (`r202633`, fleet rollout projected for early September 2026). When the fleet has moved, consumers drop this package from their requirements. Published versions stay on +PyPI for anyone still running older firmware. diff --git a/packages/schema-0/pyproject.toml b/packages/schema-0/pyproject.toml new file mode 100644 index 0000000..b7d6a58 --- /dev/null +++ b/packages/schema-0/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "span-panel-api-schema-0" +version = "1.0.0b1" +description = "Flat-schema (data-model-version absent) parser for span-panel-api" +authors = [ + {name = "SpanPanel"} +] +readme = "README.md" +license = "MIT" +requires-python = ">=3.10,<4.0" +dependencies = [ + "span-panel-api>=3.0.0b1,<4.0", +] + +[project.urls] +Homepage = "https://github.com/SpanPanel/span-panel-api" +Issues = "https://github.com/SpanPanel/span-panel-api/issues" + +# The whole point of this distribution. The bootstrap finds this adapter by +# discovering the group, never by importing this package. +[project.entry-points."span_panel_api.schema_adapters"] +schema_0 = "span_panel_api_schema_0:SchemaZeroAdapter" + +# Resolve the bootstrap from the workspace when developing here. Published +# wheels are unaffected: this table is uv-only metadata and the dependency +# above is what a consumer installing from PyPI sees. +[tool.uv.sources] +span-panel-api = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/span_panel_api_schema_0"] diff --git a/src/span_panel_api/_impl/schema_0/__init__.py b/packages/schema-0/src/span_panel_api_schema_0/__init__.py similarity index 88% rename from src/span_panel_api/_impl/schema_0/__init__.py rename to packages/schema-0/src/span_panel_api_schema_0/__init__.py index e5c10b5..6b7b783 100644 --- a/src/span_panel_api/_impl/schema_0/__init__.py +++ b/packages/schema-0/src/span_panel_api_schema_0/__init__.py @@ -1,6 +1,6 @@ """Flat-schema adapter package (data-model-version absent).""" -from span_panel_api._impl.schema_0.adapter import SchemaZeroAdapter +from span_panel_api_schema_0.adapter import SchemaZeroAdapter # Re-exported from the adapter rather than restated. The protocol requires the # range as a class attribute, so the class is the source of truth; a second diff --git a/src/span_panel_api/_impl/schema_0/accumulator.py b/packages/schema-0/src/span_panel_api_schema_0/accumulator.py similarity index 99% rename from src/span_panel_api/_impl/schema_0/accumulator.py rename to packages/schema-0/src/span_panel_api_schema_0/accumulator.py index 8b82e7f..eeae58f 100644 --- a/src/span_panel_api/_impl/schema_0/accumulator.py +++ b/packages/schema-0/src/span_panel_api_schema_0/accumulator.py @@ -13,8 +13,8 @@ import logging import time -from span_panel_api._impl.schema_0.const import TOPIC_PREFIX from span_panel_api.mqtt.const import HOMIE_STATE_DISCONNECTED, HOMIE_STATE_LOST, HOMIE_STATE_READY +from span_panel_api_schema_0.const import TOPIC_PREFIX _LOGGER = logging.getLogger(__name__) diff --git a/src/span_panel_api/_impl/schema_0/adapter.py b/packages/schema-0/src/span_panel_api_schema_0/adapter.py similarity index 88% rename from src/span_panel_api/_impl/schema_0/adapter.py rename to packages/schema-0/src/span_panel_api_schema_0/adapter.py index e03a3c1..d3dce43 100644 --- a/src/span_panel_api/_impl/schema_0/adapter.py +++ b/packages/schema-0/src/span_panel_api_schema_0/adapter.py @@ -10,10 +10,10 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator -from span_panel_api._impl.schema_0.const import PROPERTY_SET_TOPIC_FMT, TYPE_CORE, WILDCARD_TOPIC_FMT -from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer -from span_panel_api._impl.schema_0.field_metadata import build_field_metadata +from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api_schema_0.const import PROPERTY_SET_TOPIC_FMT, TYPE_CORE, WILDCARD_TOPIC_FMT +from span_panel_api_schema_0.consumer import HomieDeviceConsumer +from span_panel_api_schema_0.field_metadata import build_field_metadata if TYPE_CHECKING: from span_panel_api.models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot diff --git a/src/span_panel_api/_impl/schema_0/const.py b/packages/schema-0/src/span_panel_api_schema_0/const.py similarity index 100% rename from src/span_panel_api/_impl/schema_0/const.py rename to packages/schema-0/src/span_panel_api_schema_0/const.py diff --git a/src/span_panel_api/_impl/schema_0/consumer.py b/packages/schema-0/src/span_panel_api_schema_0/consumer.py similarity index 99% rename from src/span_panel_api/_impl/schema_0/consumer.py rename to packages/schema-0/src/span_panel_api_schema_0/consumer.py index ba7a29c..ef716f4 100644 --- a/src/span_panel_api/_impl/schema_0/consumer.py +++ b/packages/schema-0/src/span_panel_api_schema_0/consumer.py @@ -12,8 +12,15 @@ import time from typing import ClassVar -from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator -from span_panel_api._impl.schema_0.const import ( +from span_panel_api.models import ( + SpanBatterySnapshot, + SpanCircuitSnapshot, + SpanEvseSnapshot, + SpanPanelSnapshot, + SpanPVSnapshot, +) +from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api_schema_0.const import ( LUGS_DOWNSTREAM, LUGS_UPSTREAM, TYPE_BESS, @@ -27,13 +34,6 @@ TYPE_PV, normalize_circuit_id, ) -from span_panel_api.models import ( - SpanBatterySnapshot, - SpanCircuitSnapshot, - SpanEvseSnapshot, - SpanPanelSnapshot, - SpanPVSnapshot, -) _LOGGER = logging.getLogger(__name__) diff --git a/src/span_panel_api/_impl/schema_0/field_metadata.py b/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py similarity index 99% rename from src/span_panel_api/_impl/schema_0/field_metadata.py rename to packages/schema-0/src/span_panel_api_schema_0/field_metadata.py index 6f5b828..6da5388 100644 --- a/src/span_panel_api/_impl/schema_0/field_metadata.py +++ b/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py @@ -15,7 +15,8 @@ from __future__ import annotations -from span_panel_api._impl.schema_0.const import ( +from span_panel_api.models import FieldMetadata, HomieSchemaTypes +from span_panel_api_schema_0.const import ( TYPE_BESS, TYPE_CIRCUIT, TYPE_CORE, @@ -26,7 +27,6 @@ TYPE_POWER_FLOWS, TYPE_PV, ) -from span_panel_api.models import FieldMetadata, HomieSchemaTypes # --------------------------------------------------------------------------- # Static mapping: (node_type, property_id) → snapshot field path diff --git a/pyproject.toml b/pyproject.toml index 715f110..7cf6846 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "2.6.4" +version = "3.0.0b1" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} @@ -22,11 +22,18 @@ Issues = "https://github.com/SpanPanel/span-panel-api/issues" [project.scripts] format-markdown = "scripts.format_markdown:main" -[project.entry-points."span_panel_api.schema_adapters"] -schema_0 = "span_panel_api._impl.schema_0:SchemaZeroAdapter" +# No [project.entry-points."span_panel_api.schema_adapters"] block here, and that +# absence is the point of Phase 1: this distribution registers no adapter and +# imports none. Adapters are separate distributions that register themselves — +# see packages/schema-0. Adding a block here would silently re-couple the +# bootstrap to a parser and undo the split. [dependency-groups] dev = [ + # The adapter is a dev dependency, never a runtime one: the bootstrap must + # remain installable without it. It is here so the test suite exercises the + # two distributions together, which is the configuration users will run. + "span-panel-api-schema-0", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "pytest-cov", @@ -47,6 +54,16 @@ dev = [ requires = ["hatchling"] build-backend = "hatchling.build" +# One repo, independent distributions. The adapter is a workspace member so the +# test suite runs against both halves together, while `uv build` in each +# directory still produces a distribution that can be installed on its own — +# which is what the adapter-less install test depends on. +[tool.uv.workspace] +members = ["packages/*"] + +[tool.uv.sources] +span-panel-api-schema-0 = { workspace = true } + [tool.hatch.build.targets.wheel] packages = ["src/span_panel_api", "scripts"] @@ -97,6 +114,10 @@ ignore = [ force-sort-within-sections = true combine-as-imports = true split-on-trailing-comma = false +# Both distributions in this workspace are first-party. Stated explicitly +# because the repo now has two source roots, and inference from a single `src/` +# would classify the adapter package as third-party. +known-first-party = ["span_panel_api", "span_panel_api_schema_0"] [tool.mypy] python_version = "3.13" @@ -126,7 +147,7 @@ ignore_missing_imports = true [tool.coverage.run] data_file = ".local_coverage_data" -source = ["src/span_panel_api"] +source = ["src/span_panel_api", "packages/schema-0/src/span_panel_api_schema_0"] omit = [ "tests/*", "*/tests/*", @@ -172,6 +193,13 @@ ignore-paths = [ [tool.pylint.messages_control] disable = [ + # Import order is enforced by ruff's isort rules (lint select "I"), which + # knows both workspace source roots via known-first-party. pylint has no + # equivalent setting — only known-standard-library and known-third-party — + # so it classifies span_panel_api_schema_0 as third-party and disagrees with + # ruff on every adapter module. One authority for import order; ruff is the + # one that can be told the truth about this layout. + "wrong-import-order", "missing-module-docstring", "missing-class-docstring", "missing-function-docstring", diff --git a/scripts/verify_adapterless_install.py b/scripts/verify_adapterless_install.py new file mode 100644 index 0000000..f3cff98 --- /dev/null +++ b/scripts/verify_adapterless_install.py @@ -0,0 +1,77 @@ +"""Verify the bootstrap distribution works with no adapter installed. + +This is the acceptance check for the Phase 1 packaging split, and it cannot be +written as a unit test: the thing under test *is* the installed distribution +metadata — which wheel carries the entry point, and whether the bootstrap's +import graph reaches a parser. A test running in the development workspace +always has the adapter importable, so it can never observe the failure this +guards against. + +Run it in a virtualenv that has ONLY span-panel-api installed: + + uv venv /tmp/bootstrap-only + VIRTUAL_ENV=/tmp/bootstrap-only uv pip install dist/span_panel_api-*.whl + VIRTUAL_ENV=/tmp/bootstrap-only uv run --no-project \ + python scripts/verify_adapterless_install.py + +Exits non-zero with a description of the first failure. +""" + +from __future__ import annotations + +import sys + + +def _fail(message: str) -> None: + print(f"FAIL: {message}", file=sys.stderr) + raise SystemExit(1) + + +def main() -> None: + # 1. The transport must import. Before the split this raised + # ModuleNotFoundError, because mqtt/__init__ and mqtt/client both reached + # into _impl/schema_0 at module scope. + try: + import span_panel_api # noqa: F401 + from span_panel_api.mqtt.client import SpanMqttClient + except ModuleNotFoundError as exc: + _fail(f"bootstrap import reaches an adapter package: {exc}") + + # 2. No adapter should be discoverable. If one is, the bootstrap wheel is + # still carrying the entry point and the split did not actually happen. + from span_panel_api.adapters import DEFAULT_ADAPTER_KEY, discover_adapters + + registry = discover_adapters() + if registry: + _fail(f"bootstrap-only install discovered adapters {sorted(registry)}; the entry point did not move") + + # 3. Constructing a client must still work — only building a parser needs an + # adapter. This is what keeps the failure at an actionable point. + from span_panel_api.exceptions import SpanPanelAdapterMissingError + from span_panel_api.mqtt.models import MqttClientConfig + + client = SpanMqttClient( + "panel.local", + "SERIAL123", + MqttClientConfig(broker_host="broker.local", username="u", password="p"), + ) + + # 4. Building a parser must raise the named error, not an opaque one, and + # must say which adapter was wanted. + try: + client._build_adapter(32) # pylint: disable=protected-access + except SpanPanelAdapterMissingError as exc: + if exc.needed != DEFAULT_ADAPTER_KEY: + _fail(f"error names adapter {exc.needed!r}, expected {DEFAULT_ADAPTER_KEY!r}") + if exc.available: + _fail(f"error reports installed adapters {exc.available} in a bootstrap-only install") + except Exception as exc: # pylint: disable=broad-exception-caught + _fail(f"expected SpanPanelAdapterMissingError, got {type(exc).__name__}: {exc}") + else: + _fail("building a parser with no adapter installed did not raise") + + print(f"OK: span-panel-api {span_panel_api.__version__} imports and fails by name with no adapter installed") + + +if __name__ == "__main__": + main() diff --git a/src/span_panel_api/_impl/__init__.py b/src/span_panel_api/_impl/__init__.py deleted file mode 100644 index b7981bd..0000000 --- a/src/span_panel_api/_impl/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Internal implementation packages. Not public API.""" diff --git a/src/span_panel_api/schema_drift.py b/src/span_panel_api/schema_drift.py index 13f3c62..5895e10 100644 --- a/src/span_panel_api/schema_drift.py +++ b/src/span_panel_api/schema_drift.py @@ -3,7 +3,7 @@ Schema-agnostic: operates purely on ``HomieSchemaTypes`` dicts (a mapping of node type to property definitions) and has no dependency on flat-schema (schema_0) internals. Lives at the bootstrap level so ``span_panel_api.mqtt`` -can call it without importing anything from ``_impl/schema_0``. +can call it without importing anything from an adapter distribution. """ from __future__ import annotations diff --git a/tests/conftest.py b/tests/conftest.py index 96e1458..725b21f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ import span_panel_api._http as _http_mod from span_panel_api.models import V2HomieSchema -from span_panel_api._impl.schema_0.const import TOPIC_PREFIX, TYPE_CORE +from span_panel_api_schema_0.const import TOPIC_PREFIX, TYPE_CORE @pytest.fixture(autouse=True) diff --git a/tests/test_accumulator.py b/tests/test_accumulator.py index 4e6d216..c33750c 100644 --- a/tests/test_accumulator.py +++ b/tests/test_accumulator.py @@ -21,8 +21,8 @@ import pytest -from span_panel_api._impl.schema_0.accumulator import HomieLifecycle, HomiePropertyAccumulator -from span_panel_api._impl.schema_0.const import TOPIC_PREFIX +from span_panel_api_schema_0.accumulator import HomieLifecycle, HomiePropertyAccumulator +from span_panel_api_schema_0.const import TOPIC_PREFIX SERIAL = "nj-2316-XXXX" PREFIX = f"{TOPIC_PREFIX}/{SERIAL}" diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py index 627cc4e..0b20f30 100644 --- a/tests/test_adapters_discovery.py +++ b/tests/test_adapters_discovery.py @@ -144,7 +144,7 @@ class NotAnAdapter: def test_a_conforming_class_is_registered() -> None: - from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api_schema_0 import SchemaZeroAdapter registry = _discover_with(_FakeEntryPoint("schema_0", SchemaZeroAdapter)) @@ -154,7 +154,7 @@ def test_a_conforming_class_is_registered() -> None: def test_one_bad_adapter_does_not_hide_the_good_ones() -> None: """A broken third-party adapter must not take down a panel whose own adapter is installed and fine.""" - from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api_schema_0 import SchemaZeroAdapter registry = _discover_with( _FakeEntryPoint("schema_9", "not a class"), @@ -165,7 +165,7 @@ def test_one_bad_adapter_does_not_hide_the_good_ones() -> None: def test_an_entry_point_that_raises_on_load_is_skipped() -> None: - from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api_schema_0 import SchemaZeroAdapter class Exploding(_FakeEntryPoint): def load(self) -> object: diff --git a/tests/test_auth_and_homie_helpers.py b/tests/test_auth_and_homie_helpers.py index cf73a1a..dc39277 100644 --- a/tests/test_auth_and_homie_helpers.py +++ b/tests/test_auth_and_homie_helpers.py @@ -8,8 +8,8 @@ import httpx import pytest -from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator -from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer, _parse_int +from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api_schema_0.consumer import HomieDeviceConsumer, _parse_int from span_panel_api.auth import _int, download_ca_cert, get_homie_schema from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelTimeoutError diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py index c50fd80..cc6337f 100644 --- a/tests/test_factory_dispatch.py +++ b/tests/test_factory_dispatch.py @@ -5,7 +5,7 @@ import pytest -from span_panel_api._impl.schema_0 import SchemaZeroAdapter +from span_panel_api_schema_0 import SchemaZeroAdapter from span_panel_api.adapters import _reset_adapter_cache from span_panel_api.exceptions import SpanPanelAdapterMissingError, SpanPanelSchemaVersionError from span_panel_api.factory import _select_adapter_key diff --git a/tests/test_field_metadata.py b/tests/test_field_metadata.py index 333776f..3a85e38 100644 --- a/tests/test_field_metadata.py +++ b/tests/test_field_metadata.py @@ -5,7 +5,7 @@ import logging from span_panel_api.models import FieldMetadata -from span_panel_api._impl.schema_0.field_metadata import build_field_metadata +from span_panel_api_schema_0.field_metadata import build_field_metadata from span_panel_api.schema_drift import log_schema_drift diff --git a/tests/test_mqtt_client_connection.py b/tests/test_mqtt_client_connection.py index b35f0d4..acab9e3 100644 --- a/tests/test_mqtt_client_connection.py +++ b/tests/test_mqtt_client_connection.py @@ -8,7 +8,7 @@ from span_panel_api.exceptions import SpanPanelError, SpanPanelStaleDataError from span_panel_api.models import SpanPanelSnapshot -from span_panel_api._impl.schema_0.const import WILDCARD_TOPIC_FMT +from span_panel_api_schema_0.const import WILDCARD_TOPIC_FMT from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.connection import AsyncMqttBridge from span_panel_api.mqtt.models import MqttClientConfig @@ -458,7 +458,7 @@ def test_client_defaults_to_the_flat_adapter() -> None: than the stored factory keeps the guarantee that mattered — a directly constructed client still parses the flat schema. """ - from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api_schema_0 import SchemaZeroAdapter from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig @@ -473,7 +473,7 @@ def test_client_defaults_to_the_flat_adapter() -> None: def test_injected_factory_receives_serial_and_panel_size() -> None: """The factory must be called with the panel_size discovered at connect, not a placeholder — panel_size drives unmapped-tab computation.""" - from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api_schema_0 import SchemaZeroAdapter from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig diff --git a/tests/test_mqtt_homie.py b/tests/test_mqtt_homie.py index e732eea..ece93ae 100644 --- a/tests/test_mqtt_homie.py +++ b/tests/test_mqtt_homie.py @@ -22,9 +22,9 @@ import pytest -from span_panel_api._impl.schema_0 import SchemaZeroAdapter -from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator -from span_panel_api._impl.schema_0.const import ( +from span_panel_api_schema_0 import SchemaZeroAdapter +from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api_schema_0.const import ( TOPIC_PREFIX, TYPE_BESS, TYPE_CIRCUIT, @@ -36,7 +36,7 @@ TYPE_POWER_FLOWS, TYPE_PV, ) -from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer +from span_panel_api_schema_0.consumer import HomieDeviceConsumer from span_panel_api.mqtt.const import HOMIE_STATE_READY, MQTT_DEFAULT_MQTTS_PORT, MQTT_DEFAULT_WS_PORT, MQTT_DEFAULT_WSS_PORT from span_panel_api.mqtt.connection import AsyncMqttBridge from span_panel_api.mqtt.models import MqttClientConfig @@ -178,18 +178,18 @@ def test_ignores_set_topics(self): class TestHomieCircuitSnapshot: def test_circuit_id_normalization(self): - from span_panel_api._impl.schema_0.const import normalize_circuit_id + from span_panel_api_schema_0.const import normalize_circuit_id assert normalize_circuit_id("aabbccdd-1122-3344-5566-778899001122") == "aabbccdd11223344556677889900112" + "2" def test_circuit_id_denormalization(self): - from span_panel_api._impl.schema_0.const import denormalize_circuit_id + from span_panel_api_schema_0.const import denormalize_circuit_id result = denormalize_circuit_id("aabbccdd11223344556677889900112" + "2") assert result == "aabbccdd-1122-3344-5566-778899001122" def test_denormalize_non_uuid(self): - from span_panel_api._impl.schema_0.const import denormalize_circuit_id + from span_panel_api_schema_0.const import denormalize_circuit_id # Non-32-char strings pass through unchanged assert denormalize_circuit_id("short") == "short" diff --git a/tests/test_protocol_conformance.py b/tests/test_protocol_conformance.py index 50101fc..48ee23e 100644 --- a/tests/test_protocol_conformance.py +++ b/tests/test_protocol_conformance.py @@ -91,7 +91,7 @@ def test_schema_adapter_construction_signature_matches_its_implementation() -> N """ import inspect - from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api_schema_0 import SchemaZeroAdapter from span_panel_api.protocol import SchemaAdapter declared = list(inspect.signature(SchemaAdapter.__init__).parameters) diff --git a/tests/test_schema_zero_adapter.py b/tests/test_schema_zero_adapter.py index 0c6f2d8..cf35094 100644 --- a/tests/test_schema_zero_adapter.py +++ b/tests/test_schema_zero_adapter.py @@ -10,7 +10,7 @@ import pytest -from span_panel_api._impl.schema_0 import SchemaZeroAdapter +from span_panel_api_schema_0 import SchemaZeroAdapter from span_panel_api.protocol import SchemaAdapter SERIAL = "sim-40t-001" diff --git a/uv.lock b/uv.lock index 1ff3b6c..9c10a96 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[manifest] +members = [ + "span-panel-api", + "span-panel-api-schema-0", +] + [[package]] name = "anyio" version = "4.12.1" @@ -488,7 +494,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -576,7 +582,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -1292,7 +1298,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "2.6.4" +version = "3.0.0b1" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1313,6 +1319,7 @@ dev = [ { name = "pytest-cov" }, { name = "radon" }, { name = "ruff" }, + { name = "span-panel-api-schema-0" }, { name = "twine" }, { name = "types-pyyaml" }, { name = "vulture" }, @@ -1338,11 +1345,23 @@ dev = [ { name = "pytest-cov" }, { name = "radon" }, { name = "ruff", specifier = ">=0.15.5" }, + { name = "span-panel-api-schema-0", editable = "packages/schema-0" }, { name = "twine" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vulture", specifier = ">=2.14" }, ] +[[package]] +name = "span-panel-api-schema-0" +version = "1.0.0b1" +source = { editable = "packages/schema-0" } +dependencies = [ + { name = "span-panel-api" }, +] + +[package.metadata] +requires-dist = [{ name = "span-panel-api", editable = "." }] + [[package]] name = "stevedore" version = "5.7.0" From 3ca783582668200d7d10cbb2a2965792ab7b5930 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:29:47 -0700 Subject: [PATCH 7/9] fix(ci): measure adapter-package coverage after the split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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%. --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2b25c4c..9054fbd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -131,6 +131,6 @@ repos: name: coverage summary entry: bash language: system - args: ['-c', 'output=$(uv run pytest tests/ --cov=src/span_panel_api --cov-config=pyproject.toml --cov-fail-under=85 -q 2>&1); status=$?; echo "$output"; exit "$status"'] + args: ['-c', 'output=$(uv run pytest tests/ --cov=src/span_panel_api --cov=packages/schema-0/src/span_panel_api_schema_0 --cov-config=pyproject.toml --cov-fail-under=85 -q 2>&1); status=$?; echo "$output"; exit "$status"'] pass_filenames: false verbose: true From fa6dd8acdd57bf78818ca716d141304fc8b3fd2f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:32:42 -0700 Subject: [PATCH 8/9] test(schema_0): assert hardcoded schema facts still resolve against source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/span_panel_api_schema_0/const.py | 20 ++ tests/test_schema_provenance.py | 177 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 tests/test_schema_provenance.py diff --git a/packages/schema-0/src/span_panel_api_schema_0/const.py b/packages/schema-0/src/span_panel_api_schema_0/const.py index 6e85d5f..e94dc5c 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/const.py +++ b/packages/schema-0/src/span_panel_api_schema_0/const.py @@ -1,5 +1,25 @@ """Constants for the flat-schema (Homie v5) parsing implementation.""" +# --------------------------------------------------------------------------- +# Provenance anchor — the schema revision every fact in this module was read +# from. `tests/test_schema_provenance.py` fails when a captured schema reports a +# different one, which is the only pre-release signal that this adapter has +# drifted from the wire it claims to parse. +# +# The field name is per-adapter, not per-bootstrap: flat firmware publishes +# `typesSchemaHash` over a `types` block, while parent/child renames it to +# `deviceClassesSchemaHash` over `deviceClasses` — the hash is renamed with the +# block it covers, so schema_1 declares its own. +# +# Content-derived, not build-derived: SPAN defines it as the SHA-256 of the +# canonicalized schema object and states the schema "may remain unchanged across +# multiple firmware releases". So it moves when the schema moves, not on every +# release — which is what makes it usable as an anchor rather than noise. +# --------------------------------------------------------------------------- +SCHEMA_ANCHOR_FIELD = "typesSchemaHash" +SCHEMA_ANCHOR = "sha256:d347556a07d98f40" +SCHEMA_ANCHOR_FIRMWARE = "spanos2/r202603/05" + # Homie v5 topic structure HOMIE_VERSION = 5 HOMIE_DOMAIN = "ebus" diff --git a/tests/test_schema_provenance.py b/tests/test_schema_provenance.py new file mode 100644 index 0000000..f628836 --- /dev/null +++ b/tests/test_schema_provenance.py @@ -0,0 +1,177 @@ +"""Provenance checks — do this adapter's hardcoded facts still match the wire? + +Design doc testing item 8, clauses 8a and 8b. This is the **only** signal that +catches adapter-axis drift before release. Every other symptom of "SPAN changed +the schema and we did not notice" shows up in production as a silent absence: a +property that stops arriving, a metadata lookup that quietly returns None, an +entity that goes unavailable without an error anywhere. + +The failure this guards against has already happened once upstream +(electrification-bus/python-sdk#27 was exactly a hardcoded fact that had stopped +resolving against its source), which is why it is worth having with a single +adapter rather than waiting for schema_1 to make comparison interesting. + +Clause 8c (does SUPPORTS_DATA_MODEL_VERSIONS still cover what the panel reports) +is deliberately absent: flat firmware publishes no version to compare against, +and the check only becomes meaningful with a second adapter. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from span_panel_api_schema_0 import const +from span_panel_api_schema_0.field_metadata import _LUGS_FALLBACK, _PROPERTY_FIELD_MAP, _lookup_property + +_FIXTURE = Path(__file__).parent / "fixtures" / "v2" / "homie_schema.json" + + +@pytest.fixture(name="schema") +def _schema() -> dict[str, Any]: + """The captured `GET /api/v2/homie/schema` response — our stand-in for the panel.""" + with _FIXTURE.open() as handle: + loaded: dict[str, Any] = json.load(handle) + return loaded + + +# --------------------------------------------------------------------------- +# 8b — anchor check +# --------------------------------------------------------------------------- + + +def test_captured_schema_still_matches_the_anchor(schema: dict[str, Any]) -> None: + """The schema revision this adapter was written against. + + A mismatch does not mean the adapter is broken — it means the schema moved + and every fact below is now unverified until someone looks. That is the + whole job of an anchor: convert a silent change into a visible one. + """ + assert schema[const.SCHEMA_ANCHOR_FIELD] == const.SCHEMA_ANCHOR, ( + f"Schema hash moved from {const.SCHEMA_ANCHOR} to {schema[const.SCHEMA_ANCHOR_FIELD]}. " + "Re-verify the facts in const.py and _PROPERTY_FIELD_MAP against the new schema, " + "then update SCHEMA_ANCHOR." + ) + + +def test_anchor_field_is_the_flat_era_name(schema: dict[str, Any]) -> None: + """Flat serves `typesSchemaHash` over `types`; parent/child renames both to + `deviceClassesSchemaHash` over `deviceClasses`. + + Pinning the name here is what stops schema_1 from inheriting a field that + does not exist on its firmware and silently getting no anchor at all. + """ + assert const.SCHEMA_ANCHOR_FIELD in schema + assert "deviceClassesSchemaHash" not in schema, "this fixture is parent/child, not flat" + assert schema["firmwareVersion"] == const.SCHEMA_ANCHOR_FIRMWARE + + +# --------------------------------------------------------------------------- +# 8a — hardcoded facts resolve against source +# --------------------------------------------------------------------------- + + +def test_homie_domain_and_version_match_the_schema(schema: dict[str, Any]) -> None: + """TOPIC_PREFIX is built from these two, so every topic this adapter + subscribes to or publishes depends on them being right.""" + assert const.HOMIE_DOMAIN == schema["homieDomain"] + assert const.HOMIE_VERSION == schema["homieVersion"] + assert const.TOPIC_PREFIX == f"{schema['homieDomain']}/{schema['homieVersion']}" + + +# Node types this adapter restates from the schema's `types` block. +_SCHEMA_DECLARED_TYPES = ( + const.TYPE_CORE, + const.TYPE_LUGS, + const.TYPE_CIRCUIT, + const.TYPE_BESS, + const.TYPE_PV, + const.TYPE_EVSE, + const.TYPE_POWER_FLOWS, +) + +# Node types real firmware publishes in $description but the schema does not +# declare. See const.py: the schema carries only the base lugs type. +_WIRE_ONLY_TYPES = ( + const.TYPE_LUGS_UPSTREAM, + const.TYPE_LUGS_DOWNSTREAM, +) + + +@pytest.mark.parametrize("node_type", _SCHEMA_DECLARED_TYPES) +def test_declared_node_types_exist_in_the_schema(node_type: str, schema: dict[str, Any]) -> None: + assert node_type in schema["types"], f"{node_type} is no longer a declared type" + + +@pytest.mark.parametrize("node_type", _WIRE_ONLY_TYPES) +def test_wire_only_types_are_absent_but_aliased(node_type: str, schema: dict[str, Any]) -> None: + """The two namespaces are not the same set, and this pins both halves. + + These types are real — confirmed against a live panel — but undeclared, so + a metadata lookup for them only works through the alias. If SPAN ever + *declares* them, the alias becomes wrong and this test says so. If someone + adds another wire-only subtype without an alias, property metadata silently + comes back empty for those nodes and this test catches that too. + """ + assert ( + node_type not in schema["types"] + ), f"{node_type} is now declared in the schema; the _LUGS_FALLBACK alias may no longer be correct" + assert node_type in _LUGS_FALLBACK, f"{node_type} is undeclared and unaliased — metadata lookups will return None" + assert _LUGS_FALLBACK[node_type] in schema["types"] + + +def test_every_mapped_property_resolves_against_the_schema(schema: dict[str, Any]) -> None: + """The core 8a assertion. + + `_PROPERTY_FIELD_MAP` is ~70 hardcoded (node_type, property_id) pairs, each + asserting a property exists on the wire. Every one must resolve through the + same lookup path `build_field_metadata` uses — otherwise that field silently + gets no unit and no datatype, and the integration renders an entity with no + device class rather than failing. + """ + unresolved = [ + f"{node_type}/{property_id} -> {field_path}" + for node_type, property_id, field_path in _PROPERTY_FIELD_MAP + if _lookup_property(schema["types"], node_type, property_id) is None + ] + + assert not unresolved, "hardcoded properties no longer in the schema:\n " + "\n ".join(unresolved) + + +def test_no_mapped_property_is_missing_a_field_path() -> None: + """Every mapping row must name a snapshot field, and no two rows may claim + the same one — a duplicate means one silently overwrites the other.""" + field_paths = [field_path for _, _, field_path in _PROPERTY_FIELD_MAP] + + assert all(field_paths), "a mapping row has an empty field path" + duplicates = {path for path in field_paths if field_paths.count(path) > 1} + assert not duplicates, f"field paths claimed by more than one property: {sorted(duplicates)}" + + +# --------------------------------------------------------------------------- +# Known, deliberate disagreements with the schema +# --------------------------------------------------------------------------- + + +def test_circuit_active_power_unit_still_disagrees_with_the_schema(schema: dict[str, Any]) -> None: + """The schema says kW. Real panels publish W. We follow the panel. + + Recorded as an asserted expectation rather than a comment because it is a + standing contradiction between our implementation and the published schema, + and the natural instinct on finding it is to "fix" the code back to kW — + which would reintroduce the 1000x error that 1eef0dc removed after checking + against real hardware. + + When this test fails because the schema now says W, the disagreement is over: + delete this test. It failing is good news. + """ + declared = schema["types"][const.TYPE_CIRCUIT]["active-power"]["unit"] + + assert declared == "kW", ( + "The schema now declares circuit active-power as " + f"{declared!r} rather than 'kW'. If that is 'W', the long-standing " + "schema-versus-hardware disagreement is resolved and this test should be deleted." + ) From 1a2999074afc5fbb9296ead7e58365938c2d0999 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:34:29 -0700 Subject: [PATCH 9/9] docs: changelogs for 3.0.0b1 and schema-0 1.0.0b1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++ packages/schema-0/CHANGELOG.md | 33 ++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 packages/schema-0/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 58882ac..4050bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0b1] - 08/2026 + +Pre-release. `span-panel-api` becomes a transport and a dispatcher that contains **no parser**. Wire formats ship as separate distributions and register themselves via entry points, so support for a new panel schema arrives by installing a package rather +than by upgrading the transport. This is prototype work being proven end to end before any decision to land it on `main`. + +### Removed + +- **BREAKING: `span-panel-api` no longer contains a parser.** Installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`. Flat-schema panels (firmware `r202603`–`r202627`) need **`span-panel-api-schema-0`** installed + alongside it: + + ```console + pip install span-panel-api span-panel-api-schema-0 + ``` + +- **BREAKING: `HomieLifecycle`, `HomiePropertyAccumulator` and `HomieDeviceConsumer` are no longer exported** from `span_panel_api` or `span_panel_api.mqtt`. All three are flat-schema-specific rather than Homie-convention-level: the accumulator filters + every topic against a single device's prefix and stores `node → prop`, which drops nearly every message under the parent/child model; `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. They now live in `span_panel_api_schema_0`. +- **Removed dead constants** `DEVICE_TOPIC_FMT`, `STATE_TOPIC_FMT`, `DESCRIPTION_TOPIC_FMT`, `PROPERTY_TOPIC_FMT` (unreferenced before the Phase 0 relocation) and `TYPE_PCS` (a real schema type this library does not consume). + +### Added + +- **`span_panel_api.adapters.resolve_adapter(key, reason)`** — the single place a missing adapter becomes a named error, used by both Tier 1 dispatch and the transport's default path. +- **`SpanPanelSchemaVersionError`**, raised when a panel reports a `data-model-version` whose schema major cannot be determined. Distinct from `SpanPanelAdapterMissingError` because the remedy differs: a missing adapter is a known schema with no installed + parser, while this is a schema no adapter can even be named for. +- **`SpanPanelAdapterMissingError` and `SpanPanelSchemaVersionError` are now exported** from the top-level package — both are errors a user sees when their panel outruns their install, so catching them should not require reaching into a private module. +- **`SchemaAdapter.__init__` is declared on the protocol.** Construction was always part of the contract (the transport resolves an adapter class from the registry and calls it), but was previously typed only as a `Callable`, leaving the signature + unchecked against implementations. +- **Entry-point validation.** `discover_adapters()` now verifies each loaded object is a class implementing the protocol before registering it, and skips it with a logged reason otherwise. One broken third-party adapter cannot take down a panel whose own + adapter is fine. +- **`scripts/verify_adapterless_install.py`** and a CI step that runs it against a venv holding only the bootstrap wheel. + +### Changed + +- **`SpanMqttClient(adapter_factory=...)` is now optional.** When omitted, the parser is resolved through entry-point discovery at `_build_adapter()` rather than imported. Resolution is lazy by design: constructing a client must not require an adapter to + be installed, only building a parser must. +- **Dispatch refuses an unreadable `data-model-version` instead of assuming flat.** Absence still means the flat schema — that is a real signal, since the property was introduced by the firmware that introduced parent/child. A value whose major _can_ be + read but whose form is non-canonical (`1`, `1.0-beta`) dispatches on that major and logs the deviation. A value with no extractable major now raises. Previously all three fell through to the flat parser, which does not fail — it produces plausible but + wrong power and energy figures. +- **Dispatch diagnostics travel through the `SpanMqttClient` constructor**, removing the window where a connected client reported a selected adapter alongside `schema_dispatch_reason='not dispatched'`. + ## [2.6.4] - 05/2026 ### Fixed diff --git a/packages/schema-0/CHANGELOG.md b/packages/schema-0/CHANGELOG.md new file mode 100644 index 0000000..e98aa4d --- /dev/null +++ b/packages/schema-0/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to `span-panel-api-schema-0` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is fixed — the flat single-device schema, SPAN firmware `r202603` through `r202627` — and is identified by `SUPPORTS_DATA_MODEL_VERSIONS` +rather than by this version number. A release here means this parser changed, never that the panel did. + +## [1.0.0b1] - 08/2026 + +Pre-release. First release as a standalone distribution. + +### Added + +- **The flat-schema parser, extracted from `span-panel-api` 2.6.4.** Relocated verbatim from `span_panel_api._impl.schema_0` to `span_panel_api_schema_0`; only import statements changed. Registers itself as `schema_0` under the + `span_panel_api.schema_adapters` entry-point group, which is the only way `span-panel-api` reaches it — the bootstrap never imports this package. +- **`SCHEMA_ANCHOR`** (`sha256:d347556a07d98f40`, firmware `spanos2/r202603/05`) — the schema revision every hardcoded fact in this package was read from, with `SCHEMA_ANCHOR_FIELD` naming the field it comes from (`typesSchemaHash`). The field is + per-adapter: parent/child firmware renames it to `deviceClassesSchemaHash` along with the block it covers, so a future `schema_1` declares its own rather than inheriting one that does not exist on its firmware. +- **Provenance tests** asserting that all 64 hardcoded `(node_type, property_id)` pairs still resolve against the captured schema, that `HOMIE_DOMAIN` / `HOMIE_VERSION` still match it, and that the two lugs subtypes real firmware publishes remain absent + from the schema _and_ present in the metadata alias table. This is the only signal that catches schema drift before release; every other symptom reaches production as a silent absence. + +### Known deviations from the published schema + +- **Circuit `active-power` is treated as watts, though the schema declares kilowatts.** Real panels publish watts; this was established against live hardware and the 1000× correction was removed accordingly. A test asserts the schema still says `kW`, so + the day SPAN corrects it we find out rather than discovering it as a factor-of-1000 error. +- **`energy.ebus.device.lugs.upstream` / `.downstream` are parsed but undeclared.** Firmware publishes these node types in `$description`; the schema declares only the base `energy.ebus.device.lugs`. Property metadata for them resolves through an alias to + the base type. + +### Retirement + +SPAN retires the flat schema in the same firmware release that introduces the parent/child model (`r202633`; fleet rollout projected, not committed, for the first two weeks of September 2026). This package stops being published once the fleet has moved. +Published versions remain on PyPI for anyone still running older firmware.