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
22 changes: 14 additions & 8 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ repos:
# `packages/schema-1/spec/` holds byte copies of the eBus specification,
# verified by byte comparison in tests/test_schema_one_conformance.py, and
# `tests/fixtures/captures/` holds byte copies of upstream reference captures
# and their LICENSE, pinned by SHA-256 in tests/test_captures_unchanged.py.
# Any hook that rewrites a file must skip them: a "fix" there would
# invalidate the comparison that makes the copies trustworthy. Non-mutating
# checks (check-json) deliberately still run, since a corrupt copy should fail.
# and their LICENSE, pinned by SHA-256 in tests/test_captures_unchanged.py, and
# the two MAIN 32 captures in tests/fixtures/ are byte copies of upstream with
# that repository's LICENSE beside them, pinned by SHA-256 in
# tests/adapter_fidelity/test_main32_fidelity.py. Any hook that rewrites a file
# must skip them: a "fix" there would silently invalidate the comparison that
# makes the copies trustworthy. Non-mutating checks (check-json) deliberately
# still run, since a corrupt copy should fail.
- id: trailing-whitespace
exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|^tests/fixtures/captures/(.*\.json|LICENSE)$|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*'
exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|^tests/fixtures/(captures/(.*\.json|LICENSE)|main32(_r202639)?-tree-v1\.json|distribution-enclosure-simulator\.LICENSE)$|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*'
- id: end-of-file-fixer
exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|^tests/fixtures/captures/(.*\.json|LICENSE)$|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*'
exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|^tests/fixtures/(captures/(.*\.json|LICENSE)|main32(_r202639)?-tree-v1\.json|distribution-enclosure-simulator\.LICENSE)$|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*'
- id: check-yaml
exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*'
- id: check-toml
Expand All @@ -27,7 +30,7 @@ repos:
exclude: '^src/span_panel_api/generated_client/.*|generate_client\.py|scripts/.*|tests/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*'
- id: mixed-line-ending
args: ['--fix=lf']
exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|^tests/fixtures/captures/(.*\.json|LICENSE)$|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*'
exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|^tests/fixtures/(captures/(.*\.json|LICENSE)|main32(_r202639)?-tree-v1\.json|distribution-enclosure-simulator\.LICENSE)$|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*'

# Ruff for formatting and linting
- repo: https://github.com/astral-sh/ruff-pre-commit
Expand Down Expand Up @@ -105,11 +108,14 @@ repos:
# names tests/, which is fine — that flag governs directory discovery, and
# a file passed by name is checked regardless.
#
# `tests/adapter_fidelity/` is checked for the same reason: its harness and
# rules are the support code every adapter fidelity cell is measured with.
#
# `scripts/capture_parent_child_reference.py` is checked for the same reason:
# every schema_1 test is written against what it writes, which is not the place
# for the one part of the tree nothing type-checks. The hand-run scripts beside
# it stay out — see `pyproject.toml`, which names them one by one.
exclude: '^src/span_panel_api/generated_client/.*|scripts/(?!capture_parent_child_reference\.py$).*|tests/(?!reference_payloads/).*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*'
exclude: '^src/span_panel_api/generated_client/.*|scripts/(?!capture_parent_child_reference\.py$).*|tests/(?!reference_payloads/|adapter_fidelity/).*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*'

# Pylint for code quality
- repo: https://github.com/pycqa/pylint
Expand Down
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Snapshot fields that held a fabricated constant can now be unknown, and with `sp
- **BREAKING FOR CONSUMERS: `SpanPanelSnapshot.uptime_s` becomes `int | None`**, and with those parsers it is `None` on a parent/child panel and before a flat panel first reports ready, where it was a constant `0`.
- **BREAKING FOR CONSUMERS: `SpanCircuitSnapshot.instant_power_update_time_s` and `energy_accum_update_time_s` become `int | None` and default to `None`**, and with those parsers they are `None` until the reading has arrived and always on a parent/child
panel, where they were `0`.
- **The `schema-0` and `schema-1` extras require `span-panel-api-schema-0` and `span-panel-api-schema-1` 1.3.0 or newer**, the adapters that report these values as unknown.

### Removed

Expand Down Expand Up @@ -506,8 +507,7 @@ Everything below is additive. Each field is `None` or empty on a panel that publ
`MQTT_FULL_REBUILD_AFTER_FAILURES` (3) consecutive failures, or immediately on any `ssl.SSLError`. The previous behavior pinned the panel's CA certificate into the paho client once at `connect()` time and re-used it across all reconnect attempts; if the
panel rotated its private CA — most plausibly during a firmware upgrade — every subsequent reconnect raised `ssl.SSLCertVerificationError` (caught by the broad `OSError` clause and silently retried) and the bridge could not recover without a config-entry
reload. The rebuild mirrors what a manual reload does without going through HA's `config_entry` teardown, so entities stay registered and the integration's grace-period logic continues to apply unchanged. The threshold-cadence design (counter reset on
every rebuild attempt, success or fail) keeps the recovery path active throughout extended outages — multi-day disconnections recover whenever the panel becomes usable again, including if the CA rotates a second time mid-outage. See
`SpanPanel_Docs/span-panel-api/2026-05-17-mqtt-ca-refresh-on-reconnect-design.md` for the full design.
every rebuild attempt, success or fail) keeps the recovery path active throughout extended outages — multi-day disconnections recover whenever the panel becomes usable again, including if the CA rotates a second time mid-outage.

### Added

Expand Down
6 changes: 4 additions & 2 deletions packages/schema-0/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "span-panel-api-schema-0"
version = "1.2.0"
version = "1.3.0b1"
description = "Flat-schema (data-model-version absent) parser for span-panel-api"
authors = [
{name = "SpanPanel"}
Expand All @@ -19,7 +19,9 @@ dependencies = [
# Raised to 3.6.0, the first bootstrap whose snapshot carries `pv_inverters`
# and the inverter's `device_id` and `node_id`, which this parser fills.
# Against an earlier one, building a snapshot fails on those arguments.
"span-panel-api>=3.6.0,<4.0",
# Raised to 3.7.0b1, whose snapshot fields accept the unknown values this
# parser now reports, where an earlier one types them as never unknown.
"span-panel-api>=3.7.0b1,<4.0",
]

[project.urls]
Expand Down
5 changes: 5 additions & 0 deletions packages/schema-1/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ Requires `span-panel-api` **3.7.0 or newer**, whose snapshot fields accept the u

- **BREAKING: snapshots no longer include synthesised `unmapped_tab_N` entries for unoccupied breaker positions, which carried no measured data**, and `panel.build_unmapped_tabs` and `const.UNMAPPED_TAB_PREFIX` are gone with them.

### Fixed

- **A circuit whose shed priority is `UNKNOWN` is no longer reported as sheddable.**
- **The grid-forming device name falls back to vendor and model instead of showing a device id.**

## [1.2.2]

Requires `span-panel-api` **3.6.2 or newer**, which waits on this parser's missing labels after a reconnect as well as on connect.
Expand Down
7 changes: 5 additions & 2 deletions packages/schema-1/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "span-panel-api-schema-1"
version = "1.2.2"
version = "1.3.0b1"
description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api"
authors = [
{name = "SpanPanel"}
Expand Down Expand Up @@ -28,9 +28,12 @@ dependencies = [
# Raised to 3.6.1, whose SpanPanelSnapshot.pv documents what this parser builds.
# Raised to 3.6.2, which waits on this parser's missing labels after a bridge
# rebuild as well as on connect; the fix spans both, so the pair moves together.
# Raised to 3.7.0b1, whose snapshot fields accept the unknown values this
# parser now reports: against an earlier one, the update times it leaves
# unset read as a constant 0.
# To rise to 3.8.0b1 when that release is cut: this parser fills snapshot
# fields new in it and imports `device_id_order` from its models.
"span-panel-api>=3.6.2,<4.0",
"span-panel-api>=3.7.0b1,<4.0",
# Only this distribution depends on the eBus SDK. The bootstrap and
# schema-0 stay clean, so a flat-panel install never pulls it in — which is
# what bounds the release coupling this dependency introduces to panels on
Expand Down
17 changes: 13 additions & 4 deletions packages/schema-1/src/span_panel_api_schema_1/circuits.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@
====================== ===========================================================
``always-on`` ``switch/relay-controllable``, inverted, where a relay is declared
``never-backup`` ``$settable`` on ``load-shed/priority``, inverted
``sheddable`` computed: ``priority != NEVER and relay-controllable``
``sheddable`` computed: a shed priority and ``relay-controllable``
====================== ===========================================================

A shed priority is ``OFF_GRID`` or ``SOC_THRESHOLD``. SPAN's r202633 changelog
derives sheddable as ``priority != NEVER`` and asks consumers to treat ``UNKNOWN``
as "not yet known" rather than as sheddable, so the rule names the priorities the
panel sheds by instead of excluding the one it never sheds.

Sign and direction are unchanged from the flat schema, and both are the reverse
of what the property names suggest. Values are in the enclosure's reference
frame: a normal load reads **negative** ``active-power`` and accumulates
Expand All @@ -31,7 +36,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Final

from span_panel_api.models import (
FEEDS_ROLES,
Expand All @@ -49,7 +54,8 @@
NODE_METER,
NODE_PCS,
NODE_SWITCH,
PRIORITY_NEVER,
PRIORITY_OFF_GRID,
PRIORITY_SOC_THRESHOLD,
PROP_ACTIVE_POWER,
PROP_CURRENT,
PROP_EXPORTED_ENERGY,
Expand Down Expand Up @@ -77,6 +83,9 @@

from ebus_sdk.homie import DiscoveredDevice

_SHED_PRIORITIES: Final = frozenset({PRIORITY_OFF_GRID, PRIORITY_SOC_THRESHOLD})
"""The `load-shed/priority` values the panel sheds a circuit by; see the module docstring."""


def _text(device: DiscoveredDevice, node: str, prop: str, default: str = "") -> str:
value = device.get_property(node, prop)
Expand Down Expand Up @@ -354,7 +363,7 @@ def build_circuit(
# user-controllability from `always-on` — so this is the same answer by
# a shorter route.
is_user_controllable=relay_controllable,
is_sheddable=priority != PRIORITY_NEVER and relay_controllable,
is_sheddable=priority in _SHED_PRIORITIES and relay_controllable,
is_never_backup=not priority_settable,
device_type=device_type,
relative_position=relative_position,
Expand Down
2 changes: 2 additions & 0 deletions packages/schema-1/src/span_panel_api_schema_1/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@
# -- Values -----------------------------------------------------------------

PRIORITY_NEVER = "NEVER"
PRIORITY_OFF_GRID = "OFF_GRID"
PRIORITY_SOC_THRESHOLD = "SOC_THRESHOLD"
UNKNOWN = "UNKNOWN"
CLOUD_CONNECTED = "CONNECTED"

Expand Down
29 changes: 26 additions & 3 deletions packages/schema-1/src/span_panel_api_schema_1/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,29 @@ def resolve_dominant_power_source(
return _POWER_SOURCE_BY_TYPE.get(device_types.get(forming, ""), UNKNOWN)


def device_display_name(device: DiscoveredDevice) -> str | None:
"""A device's readable name, or `None` when nothing it publishes names it.

Homie's `$description.name` (`Battery`, `Solar`, `SPAN Drive - Garage`) where it
is set and is not the device's own id. Real r202639 panels publish each non-root
device's id there, and an id shown as a name is the outcome
`resolve_grid_forming_device_name` exists to avoid. Otherwise the name comes from
the device's `info`: vendor and model together (`Tesla Powerwall 2 AC`), or the
model alone. A vendor alone names a maker rather than a device, so it gives `None`.

An empty value names nothing, exactly as an unpublished one does.
"""
description: dict[str, object] = device.description or {}
name = description.get("name")
if name and str(name) != device.device_id:
return str(name)
vendor = text(device, NODE_INFO, PROP_VENDOR_NAME)
model = text(device, NODE_INFO, PROP_MODEL)
if vendor and model:
return f"{vendor} {model}"
return model or None


def resolve_grid_forming_device_name(
mid: DiscoveredDevice | None,
device_names: Mapping[str, str],
Expand All @@ -833,9 +856,9 @@ def resolve_grid_forming_device_name(

The wire value is a Homie device id -- `sim-40t-001-SIM-BESS-40T-001`. That means
nothing to someone reading a dashboard: it is not a Home Assistant device id, and an
opaque string is worse than no string. Homie's `$description.name` is the device's
own display name (`Battery`, `Solar`, `SPAN Drive - Garage`), which is what a person
would recognise, so that is what gets surfaced.
opaque string is worse than no string. `device_names` holds each device's
`device_display_name`, which is what a person would recognise, so that is what gets
surfaced.

`None` when the grid is forming (there is no device to name), when no MID publishes
an answer, or when the id resolves to nothing -- the raw id is still on
Expand Down
8 changes: 2 additions & 6 deletions packages/schema-1/src/span_panel_api_schema_1/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from span_panel_api_schema_1.panel import (
PanelFields,
build_pcs,
device_display_name,
find_lugs,
panel_positions,
panel_size_from_model,
Expand Down Expand Up @@ -182,12 +183,7 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice]) ->
# is what makes PANEL_BACKUP distinguishable from PANEL_OFF_GRID authoritatively
# instead of guessed from a power source the way flat had to.
device_types = {device.device_id: device_type(device) for device in children}
device_names: dict[str, str] = {}
for device in children:
description: dict[str, object] = device.description or {}
name = description.get("name")
if name:
device_names[device.device_id] = str(name)
device_names = {device.device_id: name for device in children if (name := device_display_name(device)) is not None}
inverters = [device for device in children if device_type(device) == TYPE_INVERTER]
islanding = resolve_islanding_state(roles.mid, panel)

Expand Down
15 changes: 11 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "span-panel-api"
version = "3.6.2"
version = "3.7.0b1"
description = "A client library for SPAN Panel API"
authors = [
{name = "SpanPanel"}
Expand Down Expand Up @@ -62,11 +62,14 @@ dependencies = [
# so a 1.1.x adapter is still accepted, but it fills neither `pv_inverters` nor
# the inverter identity fields 3.6.0 adds. Upgrading through the extra is what
# brings the adapters that do.
schema-0 = ["span-panel-api-schema-0>=1.2.0"]
# Raised to 1.3.0b1 for 3.7.0b1: that adapter reports the unknown values this
# bootstrap's snapshot fields now accept, and stops synthesising unoccupied positions.
schema-0 = ["span-panel-api-schema-0>=1.3.0b1"]
# Raised to 1.2.1 for 3.6.1: that adapter builds the `pv` that `SpanPanelSnapshot.pv` now documents.
# Raised to 1.2.2 for 3.6.2: the readiness gate every snapshot passes asks that
# adapter what is still missing, and 1.2.2 restarts its feed grace correctly.
schema-1 = ["span-panel-api-schema-1>=1.2.2"]
# Raised to 1.3.0b1 for 3.7.0b1, for the same reason as schema-0's.
schema-1 = ["span-panel-api-schema-1>=1.3.0b1"]

[project.urls]
Homepage = "https://github.com/SpanPanel/span-panel-api"
Expand Down Expand Up @@ -245,7 +248,11 @@ warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
explicit_package_bases = true
mypy_path = "src"
# `tests` because the suite imports its support packages top-level
# (`from reference_payloads.schema_one import ...`), the way pytest's rootdir puts
# `tests/` on `sys.path`; the support packages the mypy hook checks resolve the same
# way here.
mypy_path = "src:tests"
# Excluded by name for the reason `[tool.ruff]` gives above: the capture script is
# checked like the library, and the hand-run tools written before that was expected
# are not.
Expand Down
6 changes: 4 additions & 2 deletions src/span_panel_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,10 @@ class SpanMidSnapshot:

The raw value above is a Homie device id, which means nothing on a dashboard — it is
not a Home Assistant device id, and an opaque string is worse than none. This is the
device's own `$description.name` (`Battery`, `Solar`, `SPAN Drive - Garage`), which
is the part a person can read. The literal stays available beside it.
device's own `$description.name` (`Battery`, `Solar`, `SPAN Drive - Garage`) where
that is not the id itself, else its vendor and model (`Tesla Powerwall 2 AC`), which
is the part a person can read; `None` when it publishes nothing that names it. The
literal stays available beside it.
"""


Expand Down
3 changes: 1 addition & 2 deletions src/span_panel_api/mqtt/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -846,8 +846,7 @@ async def _rebuild_client(self) -> bool:
retrying with it.

Recovery target: CA rotation (firmware upgrade), stale paho client
internal state, stuck Homie accumulator. See the design doc at
SpanPanel_Docs/span-panel-api/2026-05-17-mqtt-ca-refresh-on-reconnect-design.md.
internal state, stuck Homie accumulator.
"""
if self._loop is None:
return False
Expand Down
5 changes: 5 additions & 0 deletions tests/adapter_fidelity/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Adapter fidelity: captured device trees replayed through the schema-1 adapter.

`harness` measures one capture and names no panel, model or capture; `rules`
documents why each measured outcome is right; the test modules pick captures.
"""
Loading
Loading