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