From 2bd88312d936881785e2d8c3a7df682af0b3abb8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:14:28 -0700 Subject: [PATCH 1/2] feat: dispatch on the panel's real data-model-version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard that refuses a parent/child panel was written, tested, and never invoked: create_span_client hardcoded data_model_version = None, so every panel resolved to the flat parser no matter what it reported. A v1.0 panel did not fail cleanly either — the flat parser reached for energy.ebus.device.circuit/space, which parent/child firmware keeps under deviceClasses, and the run died on "Schema missing 'energy.ebus.device.circuit/space' property": a complaint about a missing property, for a panel whose actual problem is that nothing installed can parse it. The Homie schema is now fetched over REST before the broker is opened, and its dataModelVersion selects the adapter. SPAN confirmed the absence of that field on this endpoint is a reliable flat-versus-parent/child signal, mirroring MQTT's info/data-model-version, and that dispatching on it before opening MQTT is supported. A 1.0 panel now raises SpanPanelAdapterMissingError naming the adapter to install. Dispatch also moved to wherever a parser is built, not just the factory path. A directly constructed SpanMqttClient — which the README documents and the integration uses — previously always resolved the flat adapter, carrying the same defect the factory had. The protocol changes shape once, here, because this is the release that breaks it: - __init__ takes the schema rather than panel_size. Deriving panel_size means reading a block only the flat schema has, so the bootstrap had to understand a wire format it is meant to know nothing about, and an adapter shaped differently had no way to say so. - build_field_metadata() takes no arguments; the adapter holds its schema. Tier 1 dispatch moved to span_panel_api.dispatch so the transport can reach it without importing the factory. adapters.py still answers "what is installed"; dispatch.py answers "what does this panel need". Also pins the enum-tolerance rule that schema_1 inherits: v1.0 requires consumers not to raise on an unrecognised value in a $format-extended enum, which is the opposite of the version rule one import away. The difference is blast radius — an unknown enum member affects one property, an unknown schema version means the whole tree may be misread. 438 tests pass, coverage 94%. --- CHANGELOG.md | 19 +++ packages/schema-0/CHANGELOG.md | 10 ++ .../src/span_panel_api_schema_0/adapter.py | 16 ++- src/span_panel_api/auth.py | 9 ++ src/span_panel_api/dispatch.py | 74 ++++++++++ src/span_panel_api/factory.py | 73 ++-------- src/span_panel_api/models.py | 8 ++ src/span_panel_api/mqtt/client.py | 82 +++++++---- src/span_panel_api/protocol.py | 22 ++- tests/conftest.py | 46 +++++-- tests/test_adapters_discovery.py | 8 +- tests/test_detection_auth.py | 48 +++++++ tests/test_factory_dispatch.py | 129 ++++++++++++++++-- tests/test_mqtt_client_connection.py | 29 ++-- tests/test_mqtt_connect_flow.py | 6 +- tests/test_mqtt_homie.py | 14 +- tests/test_protocol_conformance.py | 2 +- tests/test_schema_zero_adapter.py | 4 +- 18 files changed, 451 insertions(+), 148 deletions(-) create mode 100644 src/span_panel_api/dispatch.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c4df4ba..083b019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ 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). +## [Unreleased] + +### Fixed + +- **`data-model-version` dispatch is live.** The factory hardcoded `None`, so the guard that refuses a parent/child panel was written, tested and never invoked — every panel resolved to the flat parser regardless of what it reported. The Homie schema is + now fetched over REST **before** the broker is opened and the version drives adapter selection, which SPAN confirmed is a reliable flat-versus-parent/child signal on that endpoint. A `1.0` panel now raises `SpanPanelAdapterMissingError` naming the + adapter to install, instead of dying inside the flat parser on a missing `energy.ebus.device.circuit/space` property. +- **A directly constructed `SpanMqttClient` dispatches too.** Building a client without `create_span_client` previously always resolved the flat adapter, so it carried the same defect the factory path had. Dispatch now happens wherever a parser is built, + and fills in `data_model_version` / `schema_dispatch_reason` rather than leaving them reading `"not dispatched"`. + +### Changed + +- **BREAKING: `SchemaAdapter.__init__` takes the schema, not a panel size.** `adapter_cls(serial_number, schema)` replaces `adapter_cls(serial_number, panel_size)`. `panel_size` is read out of a block only the flat schema has, so the bootstrap had to + understand a wire format it is meant to know nothing about, and an adapter whose schema is shaped differently had no way to say so. Each adapter now reads what its own format defines. +- **BREAKING: `SchemaAdapter.build_field_metadata()` takes no arguments.** It previously received `schema.types` — again a flat-shaped parameter on a format-agnostic protocol. The adapter holds the schema it was constructed with. +- **`V2HomieSchema.data_model_version`** carries the `dataModelVersion` field, `None` when the panel omits it. Absence is the flat signal and stays distinct from an empty string. +- **Tier 1 dispatch moved to `span_panel_api.dispatch.select_adapter_key`** from the private `factory._select_adapter_key`, so the transport can dispatch without importing the factory. `adapters.py` continues to answer "what is installed"; the new module + answers "what does this panel need". + ## [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 diff --git a/packages/schema-0/CHANGELOG.md b/packages/schema-0/CHANGELOG.md index e3ef55e..08f2ff5 100644 --- a/packages/schema-0/CHANGELOG.md +++ b/packages/schema-0/CHANGELOG.md @@ -7,6 +7,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 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. +## [Unreleased] + +### Changed + +- **BREAKING: `SchemaZeroAdapter(serial_number, schema)`** replaces `SchemaZeroAdapter(serial_number, panel_size)`, following the protocol change in `span-panel-api`. Panel size is now derived here, by reading the circuit `space` format out of the flat + schema's `types` block — knowledge that belongs to this package rather than to the transport, which was previously doing it on every adapter's behalf. +- **`build_field_metadata()` takes no arguments**, reading the schema this adapter was constructed with. + +Requires `span-panel-api` with the reshaped `SchemaAdapter` protocol; the dependency floor is raised accordingly at release. + ## [1.0.0b1] - 08/2026 Pre-release. First release as a standalone distribution. diff --git a/packages/schema-0/src/span_panel_api_schema_0/adapter.py b/packages/schema-0/src/span_panel_api_schema_0/adapter.py index d3dce43..ce279bf 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/adapter.py +++ b/packages/schema-0/src/span_panel_api_schema_0/adapter.py @@ -16,7 +16,7 @@ from span_panel_api_schema_0.field_metadata import build_field_metadata if TYPE_CHECKING: - from span_panel_api.models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot + from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema class SchemaZeroAdapter: @@ -25,10 +25,16 @@ class SchemaZeroAdapter: schema_major = "schema_0" SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=0", "<1.0") - def __init__(self, serial_number: str, panel_size: int) -> None: + def __init__(self, serial_number: str, schema: V2HomieSchema) -> None: self._serial_number = serial_number + # `panel_size` is derived here rather than handed in, because deriving + # it means reading the flat schema's `types` block for the circuit + # `space` format — knowledge that belongs to this package. The + # transport used to do this on every adapter's behalf, which only + # worked while every adapter was this one. + self._schema = schema self._accumulator = HomiePropertyAccumulator(serial_number) - self._consumer = HomieDeviceConsumer(self._accumulator, panel_size) + self._consumer = HomieDeviceConsumer(self._accumulator, schema.panel_size) def topics_to_subscribe(self) -> list[str]: return [WILDCARD_TOPIC_FMT.format(serial=self._serial_number)] @@ -42,8 +48,8 @@ def is_ready(self) -> bool: def build_snapshot(self) -> SpanPanelSnapshot: return self._consumer.build_snapshot() - def build_field_metadata(self, schema_types: HomieSchemaTypes) -> dict[str, FieldMetadata]: - return build_field_metadata(schema_types) + def build_field_metadata(self) -> dict[str, FieldMetadata]: + return build_field_metadata(self._schema.types) def circuit_nodes_missing_names(self) -> list[str]: return self._consumer.circuit_nodes_missing_names() diff --git a/src/span_panel_api/auth.py b/src/span_panel_api/auth.py index 4df1a89..ffee86a 100644 --- a/src/span_panel_api/auth.py +++ b/src/span_panel_api/auth.py @@ -206,10 +206,19 @@ async def get_homie_schema( types_json = json.dumps(data.get("types", {}), sort_keys=True) schema_hash = "sha256:" + hashlib.sha256(types_json.encode()).hexdigest()[:16] + # Read before anything else interprets the payload. A parent/child response + # carries `deviceClasses` where this one reads `types`, so every field below + # degrades to empty for such a panel — which is harmless only because this + # value routes it to a different parser before those fields are used. + # Absence is the flat signal and must stay distinct from an empty string. + raw_data_model_version = data.get("dataModelVersion") + data_model_version = None if raw_data_model_version is None else str(raw_data_model_version) + return V2HomieSchema( firmware_version=str(data.get("firmwareVersion", "")), types_schema_hash=schema_hash, types=types, + data_model_version=data_model_version, ) diff --git a/src/span_panel_api/dispatch.py b/src/span_panel_api/dispatch.py new file mode 100644 index 0000000..72a8b66 --- /dev/null +++ b/src/span_panel_api/dispatch.py @@ -0,0 +1,74 @@ +"""Tier 1 dispatch: a panel's data-model-version selects the adapter major. + +Separate from ``adapters.py`` because they answer different questions. +``adapters.py`` knows *what is installed*; this module knows *what this panel +needs*. Keeping them apart is also what lets both the factory and the transport +dispatch without importing each other. +""" + +from __future__ import annotations + +import logging +import re + +from .adapters import DEFAULT_ADAPTER_KEY +from .exceptions import SpanPanelSchemaVersionError + +_LOGGER = logging.getLogger(__name__) + +# 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]: + """Return the adapter key this panel needs, and why. + + 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. SPAN confirmed this holds over REST + as well as MQTT, which is what makes dispatch possible before the broker is + opened. + + 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. + + Note this is the opposite of the rule for enum *properties*, where the spec + requires consumers not to raise on an unrecognised value. The difference is + blast radius: an unknown enum value affects one property, while an unknown + schema version means every value in the tree may be misread. + + Raises: + SpanPanelSchemaVersionError: A version is present but no major can be + extracted from it. + """ + if data_model_version is None: + return DEFAULT_ADAPTER_KEY, "data-model-version absent (flat schema)" + + 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)", + ) + + raise SpanPanelSchemaVersionError(data_model_version) diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py index dd066d1..50cbb46 100644 --- a/src/span_panel_api/factory.py +++ b/src/span_panel_api/factory.py @@ -7,12 +7,12 @@ from __future__ import annotations import logging -import re -from .adapters import DEFAULT_ADAPTER_KEY, resolve_adapter -from .auth import register_v2 +from .adapters import resolve_adapter +from .auth import get_homie_schema, register_v2 from .detection import detect_api_version -from .exceptions import SpanPanelAuthError, SpanPanelSchemaVersionError +from .dispatch import select_adapter_key +from .exceptions import SpanPanelAuthError from .mqtt.client import SpanMqttClient from .mqtt.models import MqttClientConfig @@ -20,56 +20,6 @@ _V2_CLIENT_NAME = "span-panel-api" -# 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]: - """Tier 1 dispatch: the panel's data-model-version selects the adapter major. - - 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 DEFAULT_ADAPTER_KEY, "data-model-version absent (flat schema)" - - 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)", - ) - - raise SpanPanelSchemaVersionError(data_model_version) - async def create_span_client( host: str, @@ -124,11 +74,13 @@ async def create_span_client( if serial_number is None: raise SpanPanelAuthError("serial_number is required for MQTT transport but could not be determined") - # Phase 0: the factory does not fetch the Homie schema, so no panel can - # report a data-model-version yet. `None` is the correct observation for - # 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) + # Dispatch reads the schema over REST before the broker is opened. SPAN + # confirmed the absence of `dataModelVersion` on this endpoint is a reliable + # flat-versus-parent/child signal, mirroring MQTT's `info/data-model-version` + # — so the parser is chosen before a single message is consumed, rather than + # a wrong parser being discovered by its output. + schema = await get_homie_schema(host, port=port) + adapter_key, dispatch_reason = select_adapter_key(schema.data_model_version) adapter_cls = resolve_adapter(adapter_key, dispatch_reason) client = SpanMqttClient( @@ -137,8 +89,9 @@ async def create_span_client( mqtt_config, panel_http_port=port, adapter_factory=adapter_cls, - data_model_version=data_model_version, + data_model_version=schema.data_model_version, schema_dispatch_reason=dispatch_reason, + schema=schema, ) await client.connect() return client diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 03d8368..84dd76b 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -143,6 +143,14 @@ class V2HomieSchema: firmware_version: str types_schema_hash: str # SHA-256, first 16 hex chars types: HomieSchemaTypes + # The flat-vs-parent/child discriminator, and the reason this endpoint is + # fetched before MQTT is opened rather than during connect(). Absent on flat + # firmware (r202603-r202627) and present from r202633, which SPAN confirmed + # is a reliable signal over REST — the same one MQTT publishes as + # ``info/data-model-version``. Defaulted so a caller constructing this model + # directly still describes a flat panel, which is what every panel in the + # field is today. + data_model_version: str | None = None @property def panel_size(self) -> int: diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 9e418eb..4caadd9 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -16,10 +16,11 @@ from span_panel_api.schema_drift import log_schema_drift -from ..adapters import DEFAULT_ADAPTER_KEY, discover_adapters, resolve_adapter +from ..adapters import discover_adapters, resolve_adapter from ..auth import get_homie_schema +from ..dispatch import select_adapter_key from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError -from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot +from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot, V2HomieSchema from ..protocol import PanelCapability, SchemaAdapter from .connection import AsyncMqttBridge from .const import MQTT_READY_TIMEOUT_S @@ -43,9 +44,10 @@ def __init__( broker_config: MqttClientConfig, snapshot_interval: float = 1.0, panel_http_port: int = 80, - adapter_factory: Callable[[str, int], SchemaAdapter] | None = None, + adapter_factory: Callable[[str, V2HomieSchema], SchemaAdapter] | None = None, data_model_version: str | None = None, schema_dispatch_reason: str | None = None, + schema: V2HomieSchema | None = None, ) -> None: self._host = host self._serial_number = serial_number @@ -67,23 +69,33 @@ def __init__( self._field_metadata: dict[str, FieldMetadata] | None = None self._schema_hash: str | None = None self._previous_schema_types: HomieSchemaTypes | None = None - # Cached at connect() so the pre-rebuild hook can reconstruct the - # 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, 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. + # Supplied by create_span_client, which already fetched it to dispatch + # on; None when constructed directly, in which case connect() fetches. + # Either way it is cached for the pre-rebuild hook, which rebuilds the + # parser after a transport-level rebuild. A panel cannot change schema + # within a session, so caching is safe. + self._schema = schema + # Diagnostics. create_span_client passes these so they are true from the + # first moment the object exists; constructing directly leaves them + # describing a client that has not dispatched yet, which connect() then + # fills in once it has a schema to dispatch on. 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: + def _build_adapter(self, schema: V2HomieSchema) -> SchemaAdapter: """Construct the parser for this session. 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: + With no injected factory this dispatches on the schema rather than + assuming the flat adapter. That matters because a client can be built + directly, bypassing create_span_client: before, such a client handed a + parent/child panel to the flat parser, which does not fail — it reports + plausible and wrong figures. Dispatch now happens on whichever path a + parser is built, so there is one answer rather than two. + + Resolving the adapter 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 @@ -91,13 +103,18 @@ def _build_adapter(self, panel_size: int) -> SchemaAdapter: is actionable. Raises: + SpanPanelSchemaVersionError: The panel reports a data-model-version + whose schema major cannot be determined. SpanPanelAdapterMissingError: No adapter_factory was supplied and no - package registers the default adapter key. + installed package registers the key this panel needs. """ 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) + adapter_key, dispatch_reason = select_adapter_key(schema.data_model_version) + self._data_model_version = schema.data_model_version + self._schema_dispatch_reason = dispatch_reason + factory = resolve_adapter(adapter_key, dispatch_reason) + self._adapter = factory(self._serial_number, schema) return self._adapter @property @@ -180,10 +197,13 @@ async def connect(self) -> None: self._loop = asyncio.get_running_loop() self._ready_event = asyncio.Event() - # Fetch schema to determine panel size and build field metadata - schema = await get_homie_schema(self._host, port=self._panel_http_port) - self._panel_size = schema.panel_size - adapter = self._build_adapter(schema.panel_size) + # create_span_client already fetched this to dispatch on; refetching + # would be a second call to the same unauthenticated endpoint for a + # value that cannot have changed. A directly-constructed client has no + # schema yet, so it fetches here and dispatches in _build_adapter. + schema = self._schema if self._schema is not None else await get_homie_schema(self._host, port=self._panel_http_port) + self._schema = schema + adapter = self._build_adapter(schema) _LOGGER.info( "MQTT adapter selected: %s (span-panel-api %s)\n data-model-version: %r\n reason: %s\n available: %s", @@ -207,8 +227,10 @@ async def connect(self) -> None: self._schema_hash = new_hash self._previous_schema_types = schema.types - # Build transport-agnostic field metadata from schema - self._field_metadata = self._require_adapter().build_field_metadata(schema.types) + # Build transport-agnostic field metadata. The adapter holds the schema + # it was constructed with, so the transport no longer has to pick out + # the block a particular wire format keeps its type definitions in. + self._field_metadata = self._require_adapter().build_field_metadata() _LOGGER.debug( "MQTT: Creating bridge to %s:%s (serial=%s)", @@ -463,14 +485,22 @@ def _on_pre_rebuild(self) -> None: and a refetch would just add cost. If the panel reboots and the schema actually changed, the existing drift-detection log fires on the next session's `connect()`. + + A cached schema is also what makes the rebuild safe to run from a + synchronous callback. ``_build_adapter`` can raise — on an unreadable + version, or on a key nothing provides — but a cached schema means + connect() already dispatched and resolved successfully on this exact + value, so neither can fail here. The guard below is what enforces that: + no schema means connect() never completed, and there is nothing to + rebuild. """ - if self._panel_size is None: - # Pre-rebuild fired before connect() cached the panel size. - # Treat as a no-op — there is no accumulator state to reset - # because connect() never completed. + if self._schema is None: + # Pre-rebuild fired before connect() cached the schema. Treat as a + # no-op — there is no accumulator state to reset because connect() + # never completed. return _LOGGER.debug("Pre-rebuild — resetting Homie accumulator") - self._build_adapter(self._panel_size) + self._build_adapter(self._schema) async def _wait_for_circuit_names(self, timeout: float) -> None: """Wait for all circuit-like nodes to have a ``name`` property. diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index 2faea5b..4fa2b35 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: - from .models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot + from .models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema class PanelCapability(Flag): @@ -93,20 +93,18 @@ class SchemaAdapter(Protocol): schema_major: str SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] - def __init__(self, serial_number: str, panel_size: int) -> None: + def __init__(self, serial_number: str, schema: V2HomieSchema) -> 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. + + Takes the whole schema rather than anything derived from it. The + previous signature passed ``panel_size``, which the transport extracted + on the adapter's behalf from a block only the flat schema has — so the + bootstrap had to understand a wire format it is supposed to know nothing + about, and any adapter whose schema is shaped differently could not say + so. Each adapter now reads what its own format defines. """ def topics_to_subscribe(self) -> list[str]: ... @@ -117,7 +115,7 @@ def is_ready(self) -> bool: ... def build_snapshot(self) -> SpanPanelSnapshot: ... - def build_field_metadata(self, schema_types: HomieSchemaTypes) -> dict[str, FieldMetadata]: ... + def build_field_metadata(self) -> dict[str, FieldMetadata]: ... def circuit_nodes_missing_names(self) -> list[str]: ... diff --git a/tests/conftest.py b/tests/conftest.py index 725b21f..aab235d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,16 +34,41 @@ def _reset_ssl_cache() -> None: # Minimal Homie description that makes the device "ready" MINIMAL_DESCRIPTION = json.dumps({"nodes": {"core": {"type": TYPE_CORE}}}) -# Mock schema for SpanMqttClient.connect() — panel_size=32 -_MOCK_SCHEMA = V2HomieSchema( - firmware_version="test", - types_schema_hash="sha256:test", - types={ - "energy.ebus.device.circuit": { - "space": {"datatype": "integer", "format": "1:32:1"}, + +def flat_schema(panel_size: int = 32) -> V2HomieSchema: + """A flat-schema REST response declaring ``panel_size`` breaker spaces. + + No ``data_model_version``: absence is exactly what marks a payload as flat, + so this is what dispatch reads to select schema_0. + """ + return V2HomieSchema( + firmware_version="test", + types_schema_hash="sha256:test", + types={ + "energy.ebus.device.circuit": { + "space": {"datatype": "integer", "format": f"1:{panel_size}:1"}, + }, }, - }, -) + ) + + +def parent_child_schema(data_model_version: str = "1.0") -> V2HomieSchema: + """A parent/child REST response, as r202633+ firmware serves it. + + ``types`` is empty because that firmware keeps its definitions under + ``deviceClasses`` — which is exactly why the version has to be read before + anything tries to parse the payload. + """ + return V2HomieSchema( + firmware_version="spanos2/r202633/01", + types_schema_hash="sha256:test", + types={}, + data_model_version=data_model_version, + ) + + +# Mock schema for SpanMqttClient.connect() — panel_size=32, flat. +MOCK_SCHEMA = flat_schema(32) # --------------------------------------------------------------------------- @@ -109,7 +134,8 @@ def _reconnect() -> int: patch("span_panel_api.mqtt.connection.AsyncMQTTClient") as cls, patch("span_panel_api.mqtt.connection.download_ca_cert", return_value="FAKE-PEM"), patch("span_panel_api.mqtt.connection._build_ssl_context", return_value=MagicMock()), - patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_MOCK_SCHEMA), + patch("span_panel_api.mqtt.client.get_homie_schema", return_value=MOCK_SCHEMA), + patch("span_panel_api.factory.get_homie_schema", return_value=MOCK_SCHEMA), ): mock_client = cls.return_value mock_client.connect.side_effect = _connect diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py index 80d3a28..41b3fed 100644 --- a/tests/test_adapters_discovery.py +++ b/tests/test_adapters_discovery.py @@ -10,6 +10,8 @@ from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig +from conftest import MOCK_SCHEMA + def test_discovers_the_self_registered_schema_zero_adapter() -> None: _reset_adapter_cache() @@ -40,7 +42,7 @@ def test_default_factory_resolves_the_flat_adapter_through_discovery() -> None: _reset_adapter_cache() client = _client() - adapter = client._build_adapter(32) + adapter = client._build_adapter(MOCK_SCHEMA) assert adapter.schema_major == DEFAULT_ADAPTER_KEY assert type(adapter) is discover_adapters()[DEFAULT_ADAPTER_KEY] @@ -61,7 +63,7 @@ def test_building_a_parser_without_any_adapter_raises_by_name() -> None: client = _client() with patch("span_panel_api.adapters._REGISTRY", {}), pytest.raises(SpanPanelAdapterMissingError) as exc: - client._build_adapter(32) + client._build_adapter(MOCK_SCHEMA) assert exc.value.needed == DEFAULT_ADAPTER_KEY assert exc.value.available == [] @@ -74,7 +76,7 @@ def test_an_explicit_factory_bypasses_discovery_entirely() -> None: 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) + adapter = client._build_adapter(MOCK_SCHEMA) assert type(adapter) is real_cls diff --git a/tests/test_detection_auth.py b/tests/test_detection_auth.py index 8131bb5..5ff9c07 100644 --- a/tests/test_detection_auth.py +++ b/tests/test_detection_auth.py @@ -428,6 +428,54 @@ async def test_parse_schema(self): assert "energy.ebus.device.distribution-enclosure.core" in result.types core_type = result.types["energy.ebus.device.distribution-enclosure.core"] assert "door" in core_type + # Real flat firmware omits dataModelVersion entirely, and that absence + # is what routes the panel to the flat parser. + assert result.data_model_version is None + + @pytest.mark.asyncio + async def test_parent_child_response_carries_its_data_model_version(self): + """The signal dispatch runs on, read over REST before MQTT is opened. + + A parent/child payload keeps its type definitions under `deviceClasses`, + so `types` comes back empty here — harmless precisely because this + version routes the panel away from the parser that would have read it. + """ + schema_json = { + "firmwareVersion": "spanos2/r202633/01", + "dataModelVersion": "1.0", + "homieDomain": "ebus", + "homieVersion": 5, + "deviceClasses": {"energy.ebus.device.panel": {}}, + } + mock_response = _mock_response(200, schema_json) + with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await get_homie_schema("192.168.65.70") + + assert result.data_model_version == "1.0" + assert result.types == {} + + @pytest.mark.asyncio + async def test_a_non_string_version_is_still_read_not_discarded(self): + """JSON may carry the version unquoted. Coercing beats treating a + present value as absent, which would silently mean "flat".""" + schema_json = {"firmwareVersion": "spanos2/r202633/01", "dataModelVersion": 1.0, "types": {}} + mock_response = _mock_response(200, schema_json) + with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await get_homie_schema("192.168.65.70") + + assert result.data_model_version == "1.0" @pytest.mark.asyncio async def test_schema_frozen(self): diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py index 5326c84..34f1ecc 100644 --- a/tests/test_factory_dispatch.py +++ b/tests/test_factory_dispatch.py @@ -8,22 +8,22 @@ 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 +from span_panel_api.dispatch import select_adapter_key from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig -from conftest import MINIMAL_DESCRIPTION, SERIAL, TOPIC_PREFIX_SERIAL +from conftest import MINIMAL_DESCRIPTION, SERIAL, TOPIC_PREFIX_SERIAL, flat_schema, parent_child_schema def test_absent_data_model_version_selects_schema_zero() -> None: - key, reason = _select_adapter_key(None) + key, reason = select_adapter_key(None) assert key == "schema_0" assert "absent" in reason @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) + key, reason = select_adapter_key(dmv) assert key == f"schema_{dmv.split('.')[0]}" assert dmv in reason @@ -35,7 +35,7 @@ def test_non_canonical_but_unambiguous_versions_dispatch_on_their_major(dmv: str 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) + key, reason = select_adapter_key(dmv) assert key == f"schema_{dmv[0]}" assert "non-canonical" in reason @@ -50,7 +50,7 @@ def test_unreadable_data_model_version_raises_instead_of_assuming_flat(dmv: str) than an error the user can see and report. """ with pytest.raises(SpanPanelSchemaVersionError) as exc: - _select_adapter_key(dmv) + select_adapter_key(dmv) assert exc.value.data_model_version == dmv @@ -58,7 +58,7 @@ def test_unreadable_data_model_version_raises_instead_of_assuming_flat(dmv: str) 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) + key, _ = select_adapter_key(None) assert key == "schema_0" @@ -71,7 +71,7 @@ def test_the_flat_key_is_the_one_the_transport_resolves() -> None: """ from span_panel_api.adapters import DEFAULT_ADAPTER_KEY - key, _ = _select_adapter_key(None) + key, _ = select_adapter_key(None) assert key == DEFAULT_ADAPTER_KEY @@ -101,7 +101,11 @@ async def test_create_span_client_wires_schema_zero_adapter_and_diagnostics() -> _reset_adapter_cache() config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") - with patch("span_panel_api.factory.SpanMqttClient") as mock_cls: + schema = flat_schema(32) + with ( + patch("span_panel_api.factory.SpanMqttClient") as mock_cls, + patch("span_panel_api.factory.get_homie_schema", return_value=schema) as mock_fetch, + ): mock_client = mock_cls.return_value mock_client.connect = AsyncMock() @@ -111,9 +115,18 @@ async def test_create_span_client_wires_schema_zero_adapter_and_diagnostics() -> serial_number="test-serial", ) + # Dispatch happens before the client exists, so the schema is fetched by the + # factory rather than by connect(). That ordering is the whole fix: the + # adapter cannot be chosen from a value that has not been read yet. + mock_fetch.assert_awaited_once() + assert result is mock_client _, kwargs = mock_cls.call_args assert kwargs["adapter_factory"] is SchemaZeroAdapter + # The fetched schema is handed to the client so connect() does not + # re-request the same unauthenticated endpoint for a value that cannot + # have changed between the two calls. + assert kwargs["schema"] is schema mock_client.connect.assert_awaited_once() # Diagnostics travel through the constructor, so they are true before # connect() rather than patched onto private state afterwards. There is no @@ -158,3 +171,101 @@ async def test_diagnostics_properties_before_and_after_connect(mqtt_client_mock: assert client.schema_dispatch_reason == "data-model-version absent (flat schema)" await client.close() + + +# --------------------------------------------------------------------------- +# Live dispatch — the version is now read, not assumed +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_a_parent_child_panel_is_refused_rather_than_parsed_as_flat() -> None: + """The bug Part A closes. + + Before, `create_span_client` hardcoded `data_model_version = None`, so a + panel reporting `1.0` was handed to the flat parser regardless of what it + said. Reverting the dispatch here shows what that cost: the flat parser + reaches for `energy.ebus.device.circuit/space`, which a parent/child + payload keeps under `deviceClasses`, and the run dies on + + ValueError: Schema missing 'energy.ebus.device.circuit/space' property + + — a message about a missing property, for a panel whose real problem is + that nothing installed can parse it. The panel is now refused by name + instead, naming the adapter to install and what is already there. + """ + from span_panel_api.factory import create_span_client + + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + with ( + patch("span_panel_api.factory.get_homie_schema", return_value=parent_child_schema()), + pytest.raises(SpanPanelAdapterMissingError) as exc, + ): + await create_span_client("192.168.1.1", mqtt_config=config, serial_number="test-serial") + + assert exc.value.needed == "schema_1" + assert "schema_0" in exc.value.available + + +@pytest.mark.asyncio +async def test_a_directly_constructed_client_dispatches_too() -> None: + """Building a client directly must not bypass dispatch. + + `create_span_client` is not the only way to get a client — the README + documents direct construction, and the integration uses it. Before, that + path always resolved the flat adapter, so it carried exactly the bug the + factory path just had fixed. Dispatch now happens wherever a parser is + built. + """ + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + client = SpanMqttClient("192.168.1.1", SERIAL, config) + with pytest.raises(SpanPanelAdapterMissingError) as exc: + client._build_adapter(parent_child_schema()) + + assert exc.value.needed == "schema_1" + + +def test_dispatch_records_what_it_read_on_the_client() -> None: + """Diagnostics for a directly-constructed client are filled in by dispatch + rather than left saying 'not dispatched' after a parser exists.""" + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + client = SpanMqttClient("192.168.1.1", SERIAL, config) + + assert client.schema_dispatch_reason == "not dispatched" + + client._build_adapter(flat_schema(32)) + + assert client.data_model_version is None + assert "absent" in client.schema_dispatch_reason + assert client.schema_major == "schema_0" + + +def test_an_unrecognised_enum_value_is_passed_through_not_raised() -> None: + """The mirror image of the version rule, and deliberately so. + + v1.0 requires consumers not to raise on an unrecognised value in a + `$format`-extended enum: SPAN may add enum members without a major bump, so + raising would take a panel offline over a value the spec allows. Dispatch + takes the opposite line on `data-model-version` because the blast radius + differs — an unknown enum member affects one property, while an unknown + schema version means every value in the tree may be misread. + + Pinned here because both rules live one import apart, and schema_1 inherits + this one. + """ + from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator + from span_panel_api_schema_0.consumer import HomieDeviceConsumer + + accumulator = HomiePropertyAccumulator(SERIAL) + consumer = HomieDeviceConsumer(accumulator, panel_size=32) + + consumer.handle_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + consumer.handle_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + # A shed-priority value no released firmware emits today. + consumer.handle_message(f"{TOPIC_PREFIX_SERIAL}/core/shed-priority", "SOME_FUTURE_PRIORITY") + + snapshot = consumer.build_snapshot() + assert snapshot is not None diff --git a/tests/test_mqtt_client_connection.py b/tests/test_mqtt_client_connection.py index acab9e3..cfef7c8 100644 --- a/tests/test_mqtt_client_connection.py +++ b/tests/test_mqtt_client_connection.py @@ -13,6 +13,8 @@ from span_panel_api.mqtt.connection import AsyncMqttBridge from span_panel_api.mqtt.models import MqttClientConfig +from conftest import flat_schema as _schema + def _make_client() -> SpanMqttClient: """Build a SpanMqttClient without I/O for unit testing.""" @@ -436,7 +438,7 @@ def cancel(self) -> None: def test_adapter_is_none_before_connect() -> None: - """The parser needs panel_size, which only connect() knows, so there is no + """The parser needs the schema, which only connect() has, so there is no adapter until then — mirroring today's `self._homie = None`.""" from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig @@ -467,21 +469,23 @@ def test_client_defaults_to_the_flat_adapter() -> None: ) assert client._adapter_factory is None - assert isinstance(client._build_adapter(40), SchemaZeroAdapter) + assert isinstance(client._build_adapter(_schema(40)), SchemaZeroAdapter) -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.""" +def test_injected_factory_receives_serial_and_schema() -> None: + """The factory must be called with the schema discovered at connect, not a + placeholder — the adapter reads panel size from it, which drives + unmapped-tab computation.""" + from span_panel_api.models import V2HomieSchema from span_panel_api_schema_0 import SchemaZeroAdapter from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig - seen: list[tuple[str, int]] = [] + seen: list[tuple[str, V2HomieSchema]] = [] - def factory(serial_number: str, panel_size: int) -> SchemaZeroAdapter: - seen.append((serial_number, panel_size)) - return SchemaZeroAdapter(serial_number=serial_number, panel_size=panel_size) + def factory(serial_number: str, schema: V2HomieSchema) -> SchemaZeroAdapter: + seen.append((serial_number, schema)) + return SchemaZeroAdapter(serial_number=serial_number, schema=schema) client = SpanMqttClient( "192.0.2.10", @@ -491,8 +495,9 @@ def factory(serial_number: str, panel_size: int) -> SchemaZeroAdapter: ) # Exercise the construction path directly rather than standing up a broker. - client._panel_size = 40 - client._build_adapter(40) + schema = _schema(40) + client._build_adapter(schema) - assert seen == [("sim-40t-001", 40)] + assert seen == [("sim-40t-001", schema)] + assert seen[0][1].panel_size == 40 assert isinstance(client.adapter, SchemaZeroAdapter) diff --git a/tests/test_mqtt_connect_flow.py b/tests/test_mqtt_connect_flow.py index 6297218..96b2805 100644 --- a/tests/test_mqtt_connect_flow.py +++ b/tests/test_mqtt_connect_flow.py @@ -764,14 +764,14 @@ async def test_pre_rebuild_preserves_schema_state(self, mqtt_client_mock: MagicM schema_hash_before = client._schema_hash schema_types_before = client._previous_schema_types field_metadata_before = client._field_metadata - panel_size_before = client._panel_size + schema_before = client._schema client._on_pre_rebuild() assert client._schema_hash == schema_hash_before assert client._previous_schema_types == schema_types_before assert client._field_metadata == field_metadata_before - assert client._panel_size == panel_size_before + assert client._schema == schema_before await client.close() @@ -780,7 +780,7 @@ async def test_pre_rebuild_before_connect_is_noop(self) -> None: """If pre-rebuild somehow fires before connect() completes, the handler must not raise — there is no accumulator state to reset.""" client = _make_span_client() - # _panel_size is None because connect() never ran. + # _schema is None because connect() never ran. client._on_pre_rebuild() # No exception, no state changes. assert client._adapter is None diff --git a/tests/test_mqtt_homie.py b/tests/test_mqtt_homie.py index ece93ae..19a163f 100644 --- a/tests/test_mqtt_homie.py +++ b/tests/test_mqtt_homie.py @@ -40,6 +40,8 @@ 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 + +from conftest import flat_schema from span_panel_api.protocol import ( PanelCapability, ) @@ -1017,7 +1019,7 @@ async def test_set_circuit_relay_publishes(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) mock_bridge = MagicMock() client._bridge = mock_bridge @@ -1036,7 +1038,7 @@ async def test_set_circuit_priority_publishes(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) mock_bridge = MagicMock() client._bridge = mock_bridge @@ -1055,7 +1057,7 @@ async def test_set_dominant_power_source_publishes(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) # Populate the homie description so core node is known desc = _make_description(_core_description()) @@ -1080,7 +1082,7 @@ async def test_set_dominant_power_source_no_core_node_raises(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) # No description loaded — core node not found with pytest.raises(SpanPanelServerError, match="Core node not found"): @@ -1099,7 +1101,7 @@ async def test_get_snapshot_returns_homie_state(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) client._bridge = _ConnectedBridge() # Manually ready the adapter @@ -1129,7 +1131,7 @@ async def test_ping_true_when_connected_and_ready(self): mock_bridge = MagicMock() mock_bridge.is_connected.return_value = True client._bridge = mock_bridge - client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) client._adapter.handle_message(f"{PREFIX}/$state", "ready") client._adapter.handle_message(f"{PREFIX}/$description", _make_description(_core_description())) diff --git a/tests/test_protocol_conformance.py b/tests/test_protocol_conformance.py index 48ee23e..5b64e81 100644 --- a/tests/test_protocol_conformance.py +++ b/tests/test_protocol_conformance.py @@ -97,7 +97,7 @@ def test_schema_adapter_construction_signature_matches_its_implementation() -> N declared = list(inspect.signature(SchemaAdapter.__init__).parameters) implemented = list(inspect.signature(SchemaZeroAdapter.__init__).parameters) - assert declared == ["self", "serial_number", "panel_size"] + assert declared == ["self", "serial_number", "schema"] assert implemented == declared, f"SchemaZeroAdapter.__init__{implemented} does not match the protocol {declared}" diff --git a/tests/test_schema_zero_adapter.py b/tests/test_schema_zero_adapter.py index cf35094..48ab729 100644 --- a/tests/test_schema_zero_adapter.py +++ b/tests/test_schema_zero_adapter.py @@ -11,6 +11,8 @@ import pytest from span_panel_api_schema_0 import SchemaZeroAdapter + +from conftest import flat_schema from span_panel_api.protocol import SchemaAdapter SERIAL = "sim-40t-001" @@ -18,7 +20,7 @@ @pytest.fixture def adapter() -> SchemaZeroAdapter: - return SchemaZeroAdapter(serial_number=SERIAL, panel_size=40) + return SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(40)) def test_satisfies_the_protocol(adapter: SchemaZeroAdapter) -> None: From 36a08c515f7a026923e5587d1f1fe24532ba78f8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:18:19 -0700 Subject: [PATCH 2/2] fix: carry the adapter-less acceptance check onto the new signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check builds a parser against a real bootstrap-only wheel, so it still passed a panel size and died on AttributeError before reaching the error it exists to assert. The unit suite could not catch this: it never runs against an install that has no adapter. It now passes a flat schema, which is also the case this check is about — every panel in the field reports no data-model-version, so dispatch asks for the default key and finds nothing providing it. --- scripts/verify_adapterless_install.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/verify_adapterless_install.py b/scripts/verify_adapterless_install.py index f3cff98..912f764 100644 --- a/scripts/verify_adapterless_install.py +++ b/scripts/verify_adapterless_install.py @@ -57,9 +57,20 @@ def main() -> None: ) # 4. Building a parser must raise the named error, not an opaque one, and - # must say which adapter was wanted. + # must say which adapter was wanted. A flat schema is used because that + # is the case a bootstrap-only install is expected to fail on: every + # panel in the field today reports no data-model-version, so dispatch + # asks for the default key and finds nothing providing it. + from span_panel_api.models import V2HomieSchema + + flat_schema = V2HomieSchema( + firmware_version="spanos2/r202603/05", + types_schema_hash="sha256:0000000000000000", + types={"energy.ebus.device.circuit": {"space": {"datatype": "integer", "format": "1:32:1"}}}, + ) + try: - client._build_adapter(32) # pylint: disable=protected-access + client._build_adapter(flat_schema) # 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}")