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
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the **last public release**, never against the beta before it. What one
beta corrected in an earlier beta does not appear at all: from the point of view of somebody upgrading between released versions, it never happened.

## [3.0.1]

### Fixed

- **`SpanPanelAdapterIncompatibleError` is exported from the top-level package.** 3.0.0 documented it there — in this changelog, in the README's error table, and in `SpanMqttClient.connect`'s own docstring, which names it as something the caller receives —
but it was omitted from `__init__.py`, so the only way to catch it was `from span_panel_api.exceptions import ...`, a path nothing else in the documentation uses. `resolve_adapter` raises it into caller hands rather than logging it, so a consumer
following the documented API got an `ImportError` at exactly the point it was trying to handle a real failure. Purely additive: the class, its attributes and its raise sites are unchanged.

### Added

- **A guard that derives the public exception surface from the module instead of transcribing it.** The existing pin compares `__all__` against a hand-written set, which catches the two drifting apart but not a name absent from both — which is precisely
how the omission above shipped. The new check enumerates every exception class defined in `span_panel_api.exceptions` and fails if one is not exported.
- **Python version classifiers in the published metadata**, so the supported version is stated rather than inferred, and so the README's Python badge is read from PyPI rather than hardcoded. The hardcoded badge read `3.10+` for the whole of 3.0.0, five
minor versions below the real floor, because nothing connected it to `requires-python`.

## [3.0.0]

`span-panel-api` becomes a transport and a dispatcher that contains **no parser**. Wire formats ship as separate distributions and register themselves through the `span_panel_api.schema_adapters` entry-point group, so support for a new panel schema arrives
Expand Down Expand Up @@ -40,7 +55,7 @@ by installing a package rather than by upgrading the transport.
where it carried the SKU. That is the deliberate trade: a change scheduled in a library release beats the same change arriving unplanned during a firmware upgrade a user did not choose the timing of.
- **Consumers reading `product_name` must move to `model` in the same release.** The Home Assistant integration builds its device-registry model from it; left unchanged, device cards go blank.
- **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 raises `SpanPanelSchemaVersionError`. Previously all three fell through to the flat parser, which does not fail — it
read but whose form is non-canonical (`1`, `1_0`) dispatches on that major and logs the deviation. A value with no extractable major raises `SpanPanelSchemaVersionError`. Previously all three fell through to the flat parser, which does not fail — it
produces plausible but wrong power and energy figures.
- **`get_homie_schema()` tells "not ready yet" apart from "will not fix itself".** Any 5xx raises `SpanPanelServerError`, a transport failure raises `SpanPanelConnectionError`, and a `200` carrying a truncated or empty body raises `SpanPanelServerError`
rather than surfacing as a parse error. A booting panel brings its network stack and reverse proxy up before the application behind them, so it answers rather than refuses; the distinction is what lets a caller retry that and not retry a 4xx.
Expand Down
31 changes: 24 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
# SPAN Panel API

[![GitHub Release](https://img.shields.io/github/v/release/SpanPanel/span-panel-api?style=flat-square)](https://github.com/SpanPanel/span-panel-api/releases)
[![PyPI Version](https://img.shields.io/pypi/v/span-panel-api?style=flat-square)](https://pypi.org/project/span-panel-api/) [![Python Versions](https://img.shields.io/badge/python-3.10+-blue?style=flat-square)](https://pypi.org/project/span-panel-api/)
[![GitHub Release](https://img.shields.io/github/v/release/SpanPanel/span-panel-api?filter=v*&style=flat-square&label=release)](https://github.com/SpanPanel/span-panel-api/releases)
[![PyPI Version](https://img.shields.io/pypi/v/span-panel-api?style=flat-square&label=span-panel-api)](https://pypi.org/project/span-panel-api/)
[![Python Versions](https://img.shields.io/pypi/pyversions/span-panel-api?style=flat-square)](https://pypi.org/project/span-panel-api/)
[![License](https://img.shields.io/github/license/SpanPanel/span-panel-api?style=flat-square)](https://github.com/SpanPanel/span-panel-api/blob/main/LICENSE)

[![schema-0](https://img.shields.io/pypi/v/span-panel-api-schema-0?style=flat-square&label=schema-0)](https://pypi.org/project/span-panel-api-schema-0/)
[![schema-1](https://img.shields.io/pypi/v/span-panel-api-schema-1?style=flat-square&label=schema-1)](https://pypi.org/project/span-panel-api-schema-1/)

[![CI Status](https://img.shields.io/github/actions/workflow/status/SpanPanel/span-panel-api/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/SpanPanel/span-panel-api/actions/workflows/ci.yml)

[![Code Quality](https://img.shields.io/codefactor/grade/github/SpanPanel/span-panel-api?style=flat-square)](https://www.codefactor.io/repository/github/spanpanel/span-panel-api)
Expand All @@ -14,12 +18,23 @@

[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support%20development-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/cayossarian)

A Python client library for the SPAN Panel v2 API, using MQTT/Homie for real-time push-based panel state.
A Python client library for the SPAN Panel API, using MQTT/Homie for real-time push-based panel state.

## Version support

| Line | Status | Needs |
| ------- | -------------------------------------------------------------------- | ------------------------- |
| **3.x** | Current. Transport and dispatcher; the parser is a separate install. | v2 firmware, Python 3.14+ |
| **2.x** | Superseded by 3.0.0 and no longer developed. Fixes land on 3.x only. | v2 firmware, Python 3.10+ |
| **1.x** | Deprecated. | v1 firmware |

**1.x is built on the SPAN v1 REST API**, which SPAN retires when v1 firmware sunsets at the end of 2026. Nothing on that line will outlive the firmware it talks to.

## v1.x Sunset Notice
**2.x still works against v2 firmware, but it is closed to new work.** Everything since — the parent/child data model, adoption, discovery, extension properties — landed on 3.x, and so will anything that comes next. Treat 2.x as a line to leave rather than
a line to stay on.

**Package versions prior to 2.0.0 are deprecated.** These versions depend on the SPAN v1 REST API, which will be retired when SPAN sunsets v1 firmware at the end of 2026. Users should upgrade to v2.0.0 or later, which requires v2 firmware
(`spanos2/r202603/05` or later) and a panel passphrase.
Moving from 2.x to 3.x is an install rather than only an upgrade: 3.0.0 removed the bundled parser, so `pip install -U span-panel-api` alone leaves you with a client that connects and then raises `SpanPanelAdapterMissingError`. See
[Installation](#installation) for the extra to name.

## Installation

Expand All @@ -43,7 +58,9 @@ The extras are the recommended spelling because they give `pip install -U` a cor
`span-panel-api` never imports a parser. Each wire format is its own distribution, registering itself under the `span_panel_api.schema_adapters` entry-point group, and the transport reaches it by key at runtime:

1. **Ask the panel first.** Before the broker is opened, the client fetches `GET /api/v2/homie/schema` over REST and reads `dataModelVersion`. Absence means the flat schema — a real signal, since the property arrived with 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; one with no extractable major raises `SpanPanelSchemaVersionError` rather than guessing.
parent/child. Current parent/child firmware reports the canonical `MAJOR.MINOR[.PATCH]` form — `1.0` — which selects `schema_1` outright. A value whose major is still unambiguous but whose form is not canonical (`1`, `1_0`) dispatches on that major and
logs the deviation, so a firmware that changes format is visible before it is an outage. A value with no readable major (`v1.0`, `x`) raises `SpanPanelSchemaVersionError` rather than guessing: falling back to flat would hand a parent/child panel to the
flat parser, which does not fail — it produces plausible but wrong figures.
2. **Enumerate without importing.** `installed_adapter_keys()` reads distribution metadata only. Nothing is imported to find out what is installed, so a flat panel never pays for `span-panel-api-schema-1` — nor for the eBus SDK underneath it.
3. **Resolve on demand, once.** The adapter for the selected key is imported the first time a panel asks for it, then cached. The async paths run enumeration and resolution in a thread, so neither blocks the event loop.
4. **Verify the contract before trusting it.** Every adapter declares `ADAPTER_CONTRACT` as a literal, and discovery rejects any that does not match this package's `ADAPTER_CONTRACT_VERSION`. Member presence is not the whole contract — a Protocol cannot
Expand Down
17 changes: 16 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "span-panel-api"
version = "3.0.0"
version = "3.0.1"
description = "A client library for SPAN Panel API"
authors = [
{name = "SpanPanel"}
Expand All @@ -9,6 +9,21 @@ readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.14,<4.0"
# Declared so the published metadata states the supported version rather than
# leaving a reader to infer it, and so the README's Python badge can be derived
# from PyPI instead of hardcoded. The hardcoded one said "3.10+" for the whole
# of 3.0.0, five minor versions below the real floor -- a number nothing checked
# because nothing could.
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: Home Automation",
"Typing :: Typed",
]
dependencies = [
# Bounded, and the bound is load-bearing. httpx 1.0 is an API rewrite that
# removes `AsyncClient`, and 1.0.dev1..dev4 are on PyPI now. The bound was
Expand Down
2 changes: 2 additions & 0 deletions src/span_panel_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
)
from .detection import DetectionResult, detect_api_version
from .exceptions import (
SpanPanelAdapterIncompatibleError,
SpanPanelAdapterMissingError,
SpanPanelAPIError,
SpanPanelAuthError,
Expand Down Expand Up @@ -148,6 +149,7 @@
"validate_solar_tabs",
# Exceptions
"SpanPanelAPIError",
"SpanPanelAdapterIncompatibleError",
"SpanPanelAdapterMissingError",
"SpanPanelAuthError",
"SpanPanelSchemaVersionError",
Expand Down
2 changes: 1 addition & 1 deletion src/span_panel_api/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def select_adapter_key(data_model_version: str | None) -> tuple[str, str]:
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
its full form is not canonical ('1', '1_0'). 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.
Expand Down
40 changes: 40 additions & 0 deletions tests/test_public_api_unchanged.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@

from __future__ import annotations

import inspect

import span_panel_api
from span_panel_api import exceptions

# Source of truth: src/span_panel_api/__init__.py __all__ (transcribed in full,
# not trimmed, per Phase 0 Task 7's instruction to reconcile against the real file
Expand Down Expand Up @@ -99,6 +102,12 @@
"validate_solar_tabs",
# Exceptions
"SpanPanelAPIError",
# Added 3.0.1: omitted from `__all__` in 3.0.0 while the README and the
# changelog both documented it as a top-level export, and while
# `SpanMqttClient.connect` names it in its own docstring as something a
# caller receives. Purely additive -- the class and its raise sites are
# unchanged, only the path a consumer imports it by.
"SpanPanelAdapterIncompatibleError",
"SpanPanelAdapterMissingError",
"SpanPanelAuthError",
"SpanPanelSchemaVersionError",
Expand All @@ -122,3 +131,34 @@ def test_all_is_unchanged() -> None:
def test_every_exported_name_is_importable() -> None:
for name in span_panel_api.__all__:
assert hasattr(span_panel_api, name), f"{name} is in __all__ but not importable"


def test_every_public_exception_is_exported() -> None:
"""Every exception this package defines is reachable from the top level.

The pin above compares `__all__` against a hand-transcribed set, so it only
catches the two drifting apart. It cannot catch a name omitted from *both* --
which is exactly how `SpanPanelAdapterIncompatibleError` shipped in 3.0.0
documented as a top-level export but importable only from
`span_panel_api.exceptions`.

This set is derived from the module instead of transcribed, so a new
exception class is exported or this fails. That matters because a consumer
cannot catch what it cannot import, and `resolve_adapter` raises these into
caller hands rather than logging them.
"""
defined = {
name
for name, obj in vars(exceptions).items()
if not name.startswith("_")
and inspect.isclass(obj)
and issubclass(obj, Exception)
and obj.__module__ == exceptions.__name__
}
assert defined, "no exception classes found; this guard would pass vacuously"

unexported = defined - set(span_panel_api.__all__)
assert not unexported, (
f"defined in span_panel_api.exceptions but not exported from the package: {sorted(unexported)}. "
"A consumer cannot catch what it cannot import."
)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading