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
29 changes: 21 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,18 @@ jobs:
enable-cache: true

- name: Install dependencies
run: uv sync
run: uv sync --all-packages

- name: Run pre-commit hooks
run: |
uv run pre-commit run --all-files

- 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



Expand All @@ -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
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions packages/schema-0/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions packages/schema-0/README.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions packages/schema-0/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
11 changes: 11 additions & 0 deletions packages/schema-0/src/span_panel_api_schema_0/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Flat-schema adapter package (data-model-version absent)."""

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
# 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"]
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions packages/schema-0/src/span_panel_api_schema_0/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""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"
TOPIC_PREFIX = f"{HOMIE_DOMAIN}/{HOMIE_VERSION}"

# 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.
#
# 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_CIRCUIT = "energy.ebus.device.circuit"
TYPE_BESS = "energy.ebus.device.bess"
TYPE_PV = "energy.ebus.device.pv"
TYPE_EVSE = "energy.ebus.device.evse"
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"


def normalize_circuit_id(node_id: str) -> str:
"""Strip dashes from Homie UUID for entity stability."""
return node_id.replace("-", "")


def denormalize_circuit_id(circuit_id: str) -> str:
"""Restore dashes to a 32-char dashless UUID (8-4-4-4-12 format)."""
if len(circuit_id) == 32 and "-" not in circuit_id:
return f"{circuit_id[:8]}-{circuit_id[8:12]}-{circuit_id[12:16]}-{circuit_id[16:20]}-{circuit_id[20:]}"
return circuit_id
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,13 +34,6 @@
TYPE_PV,
normalize_circuit_id,
)
from span_panel_api.models import (
SpanBatterySnapshot,
SpanCircuitSnapshot,
SpanEvseSnapshot,
SpanPanelSnapshot,
SpanPVSnapshot,
)

_LOGGER = logging.getLogger(__name__)

Expand Down
Loading
Loading