Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/schema-0/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 11 additions & 5 deletions packages/schema-0/src/span_panel_api_schema_0/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)]
Expand All @@ -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()
Expand Down
15 changes: 13 additions & 2 deletions scripts/verify_adapterless_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
9 changes: 9 additions & 0 deletions src/span_panel_api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down
74 changes: 74 additions & 0 deletions src/span_panel_api/dispatch.py
Original file line number Diff line number Diff line change
@@ -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)
73 changes: 13 additions & 60 deletions src/span_panel_api/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,69 +7,19 @@
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

_LOGGER = logging.getLogger(__name__)

_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,
Expand Down Expand Up @@ -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(
Expand All @@ -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
8 changes: 8 additions & 0 deletions src/span_panel_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading