span-panel-api 2.6.4__tar.gz → 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- span_panel_api-3.0.0/.gitignore +47 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/CHANGELOG.md +140 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/PKG-INFO +151 -38
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/README.md +143 -34
- span_panel_api-3.0.0/pyproject.toml +327 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/__init__.py +47 -3
- span_panel_api-3.0.0/src/span_panel_api/adapters.py +232 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/auth.py +117 -19
- span_panel_api-3.0.0/src/span_panel_api/dispatch.py +74 -0
- span_panel_api-3.0.0/src/span_panel_api/exceptions.py +111 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/factory.py +42 -4
- span_panel_api-3.0.0/src/span_panel_api/models.py +1122 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/mqtt/__init__.py +5 -6
- span_panel_api-3.0.0/src/span_panel_api/mqtt/client.py +1128 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/mqtt/connection.py +14 -1
- span_panel_api-3.0.0/src/span_panel_api/mqtt/const.py +30 -0
- span_panel_api-3.0.0/src/span_panel_api/protocol.py +224 -0
- {span_panel_api-2.6.4/tests/fixtures/v2 → span_panel_api-3.0.0/src/span_panel_api/reference_payloads}/README.md +7 -11
- span_panel_api-3.0.0/src/span_panel_api/reference_payloads/__init__.py +66 -0
- span_panel_api-3.0.0/src/span_panel_api/schema_drift.py +65 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/conftest.py +67 -11
- span_panel_api-3.0.0/tests/fixtures/flat_wire.json +563 -0
- span_panel_api-3.0.0/tests/fixtures/panelbench_unvalued_by_both.json +125 -0
- span_panel_api-3.0.0/tests/fixtures/v2/README.md +14 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_accumulator.py +2 -2
- span_panel_api-3.0.0/tests/test_adapters_discovery.py +398 -0
- span_panel_api-3.0.0/tests/test_adopted_control.py +188 -0
- span_panel_api-3.0.0/tests/test_adoption.py +414 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_auth_and_homie_helpers.py +98 -5
- span_panel_api-3.0.0/tests/test_catalog_divergence.py +685 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_detection_auth.py +156 -7
- span_panel_api-3.0.0/tests/test_factory_dispatch.py +310 -0
- span_panel_api-3.0.0/tests/test_field_metadata.py +726 -0
- span_panel_api-3.0.0/tests/test_live_flat_differential.py +166 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_mqtt_client_connection.py +125 -17
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_mqtt_connect_flow.py +90 -19
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_mqtt_homie.py +31 -33
- span_panel_api-3.0.0/tests/test_packaging.py +69 -0
- span_panel_api-3.0.0/tests/test_protocol_conformance.py +119 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_protocol_models.py +0 -1
- span_panel_api-3.0.0/tests/test_public_api_unchanged.py +124 -0
- span_panel_api-3.0.0/tests/test_redispatch_on_reconnect.py +482 -0
- span_panel_api-3.0.0/tests/test_reference_tree_values.py +140 -0
- span_panel_api-3.0.0/tests/test_schema_generation_cross_check.py +119 -0
- span_panel_api-3.0.0/tests/test_schema_migration_delta.py +579 -0
- span_panel_api-3.0.0/tests/test_schema_one_adapter.py +472 -0
- span_panel_api-3.0.0/tests/test_schema_one_against_simulator.py +260 -0
- span_panel_api-3.0.0/tests/test_schema_one_charge_limit.py +540 -0
- span_panel_api-3.0.0/tests/test_schema_one_circuits.py +183 -0
- span_panel_api-3.0.0/tests/test_schema_one_conformance.py +726 -0
- span_panel_api-3.0.0/tests/test_schema_one_connection_health.py +371 -0
- span_panel_api-3.0.0/tests/test_schema_one_devices.py +488 -0
- span_panel_api-3.0.0/tests/test_schema_one_discovery.py +553 -0
- span_panel_api-3.0.0/tests/test_schema_one_extension.py +303 -0
- span_panel_api-3.0.0/tests/test_schema_one_panel.py +614 -0
- span_panel_api-3.0.0/tests/test_schema_one_pcs.py +522 -0
- span_panel_api-3.0.0/tests/test_schema_one_service_entrance.py +196 -0
- span_panel_api-3.0.0/tests/test_schema_one_shed_forecast.py +289 -0
- span_panel_api-3.0.0/tests/test_schema_one_snapshot.py +140 -0
- span_panel_api-3.0.0/tests/test_schema_one_transport.py +233 -0
- span_panel_api-3.0.0/tests/test_schema_provenance.py +177 -0
- span_panel_api-3.0.0/tests/test_schema_zero_adapter.py +87 -0
- span_panel_api-3.0.0/tests/test_shared_http_client.py +121 -0
- span_panel_api-3.0.0/tests/test_ssl_context.py +234 -0
- span_panel_api-2.6.4/.codefactor +0 -29
- span_panel_api-2.6.4/.codefactor.yml +0 -18
- span_panel_api-2.6.4/.deps-installed +0 -0
- span_panel_api-2.6.4/.github/ISSUE_TEMPLATE/bug_report.md +0 -37
- span_panel_api-2.6.4/.github/ISSUE_TEMPLATE/feature_request.md +0 -31
- span_panel_api-2.6.4/.github/dependabot.yml +0 -62
- span_panel_api-2.6.4/.github/workflows/ci.yml +0 -101
- span_panel_api-2.6.4/.github/workflows/dependabot-auto-approve.yml +0 -38
- span_panel_api-2.6.4/.github/workflows/dependabot-auto-merge.yml +0 -56
- span_panel_api-2.6.4/.github/workflows/release.yml +0 -42
- span_panel_api-2.6.4/.gitignore +0 -36
- span_panel_api-2.6.4/.markdownlint-cli2.jsonc +0 -49
- span_panel_api-2.6.4/.markdownlint.json +0 -11
- span_panel_api-2.6.4/.pre-commit-config.yaml +0 -136
- span_panel_api-2.6.4/.prettierrc.json +0 -25
- span_panel_api-2.6.4/.vscode/extensions.json +0 -5
- span_panel_api-2.6.4/.vscode/tasks.json +0 -131
- span_panel_api-2.6.4/DEVELOPMENT.md +0 -79
- span_panel_api-2.6.4/SECURITY.md +0 -11
- span_panel_api-2.6.4/conftest.py +0 -18
- span_panel_api-2.6.4/developer_attribute_readme.md +0 -276
- span_panel_api-2.6.4/openapi.json +0 -1621
- span_panel_api-2.6.4/pyproject.toml +0 -213
- span_panel_api-2.6.4/pytest.ini +0 -2
- span_panel_api-2.6.4/pytest_output.log +0 -11
- span_panel_api-2.6.4/scripts/__init__.py +0 -1
- span_panel_api-2.6.4/scripts/coverage.py +0 -118
- span_panel_api-2.6.4/scripts/format.sh +0 -25
- span_panel_api-2.6.4/scripts/format_markdown.py +0 -39
- span_panel_api-2.6.4/scripts/test_live_auth.py +0 -311
- span_panel_api-2.6.4/setup-hooks.sh +0 -31
- span_panel_api-2.6.4/src/span_panel_api/exceptions.py +0 -42
- span_panel_api-2.6.4/src/span_panel_api/models.py +0 -231
- span_panel_api-2.6.4/src/span_panel_api/mqtt/accumulator.py +0 -273
- span_panel_api-2.6.4/src/span_panel_api/mqtt/client.py +0 -489
- span_panel_api-2.6.4/src/span_panel_api/mqtt/const.py +0 -71
- span_panel_api-2.6.4/src/span_panel_api/mqtt/field_metadata.py +0 -229
- span_panel_api-2.6.4/src/span_panel_api/mqtt/homie.py +0 -635
- span_panel_api-2.6.4/src/span_panel_api/protocol.py +0 -79
- span_panel_api-2.6.4/tests/test_field_metadata.py +0 -280
- span_panel_api-2.6.4/tests/test_protocol_conformance.py +0 -48
- span_panel_api-2.6.4/uv.lock +0 -1518
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/LICENSE +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-2.6.4/tests/fixtures/v2 → span_panel_api-3.0.0/src/span_panel_api/reference_payloads}/homie_schema.json +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_exceptions.py +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_phase_validation_errors.py +0 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
build/
|
|
3
|
+
dist/
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
.cursor/
|
|
7
|
+
.cursorignore
|
|
8
|
+
.cursor
|
|
9
|
+
.cursorindexingignore
|
|
10
|
+
xnotes/*
|
|
11
|
+
|
|
12
|
+
# pyenv
|
|
13
|
+
.python-version
|
|
14
|
+
|
|
15
|
+
# Environments
|
|
16
|
+
.env
|
|
17
|
+
.envrc
|
|
18
|
+
.venv
|
|
19
|
+
.vscode/settings.json
|
|
20
|
+
|
|
21
|
+
# mypy
|
|
22
|
+
.mypy_cache/
|
|
23
|
+
.dmypy.json
|
|
24
|
+
dmypy.json
|
|
25
|
+
|
|
26
|
+
# ruff
|
|
27
|
+
.ruff_cache/
|
|
28
|
+
|
|
29
|
+
# JetBrains
|
|
30
|
+
.idea/
|
|
31
|
+
|
|
32
|
+
/coverage.xml
|
|
33
|
+
/.coverage
|
|
34
|
+
coverage_output.log
|
|
35
|
+
**/.DS_Store
|
|
36
|
+
.local_coverage_data
|
|
37
|
+
|
|
38
|
+
# Captures taken from a real panel. These carry the panel's serial (which is also
|
|
39
|
+
# its MQTT username), the household's circuit names, and real consumption — none
|
|
40
|
+
# of which belongs in a repository. The differential that reads them commits its
|
|
41
|
+
# *verdict* only, never the capture, and skips when the file is absent.
|
|
42
|
+
tests/fixtures/live_*.json
|
|
43
|
+
|
|
44
|
+
# Peer checkouts. CI clones the eBus specification and SpanPanel/panelbench here so
|
|
45
|
+
# the provenance checks have something to compare vendored bytes against; the same
|
|
46
|
+
# layout works locally if you would rather not point .env at siblings.
|
|
47
|
+
/peers/
|
|
@@ -4,6 +4,146 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
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).
|
|
6
6
|
|
|
7
|
+
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
|
|
8
|
+
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.
|
|
9
|
+
|
|
10
|
+
## [3.0.0]
|
|
11
|
+
|
|
12
|
+
`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
|
|
13
|
+
by installing a package rather than by upgrading the transport.
|
|
14
|
+
|
|
15
|
+
### Removed
|
|
16
|
+
|
|
17
|
+
- **BREAKING: `span-panel-api` no longer contains a parser.** Installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`. A parser is an install:
|
|
18
|
+
|
|
19
|
+
```console
|
|
20
|
+
# flat-schema panels, firmware r202603-r202627
|
|
21
|
+
pip install "span-panel-api[schema-0]"
|
|
22
|
+
|
|
23
|
+
# parent/child panels, firmware r202633+
|
|
24
|
+
pip install "span-panel-api[schema-1]"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The adapter distributions can equally be named directly; the extras exist because the dependency arrow runs the other way — an adapter declares a floor on the bootstrap, the bootstrap requires no adapter — so upgrading the bootstrap alone would otherwise
|
|
28
|
+
leave a stale adapter wheel that discovery then rejects, with pip reporting success. The bootstrap never imports an adapter, and supporting a new panel schema on an existing install is an install rather than an upgrade.
|
|
29
|
+
|
|
30
|
+
- **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
|
|
31
|
+
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
|
|
32
|
+
received ⇒ ready", which is the flat readiness model. They now live in `span_panel_api_schema_0`.
|
|
33
|
+
- **Removed dead constants** `DEVICE_TOPIC_FMT`, `STATE_TOPIC_FMT`, `DESCRIPTION_TOPIC_FMT`, `PROPERTY_TOPIC_FMT` (unreferenced) and `TYPE_PCS` (a real schema type this library does not consume).
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- **BREAKING — DER identity speaks the parent/child vocabulary on every device class.** `model` is the human designation and `part_number` the SKU, on `battery`, `evse` and `pv` alike. `product_name` is retired on all three. Flat is the inconsistent side:
|
|
38
|
+
it puts the SKU in `bess/model` and in `evse/part-number`, the same concept under two names, and gives PV neither. Mirroring that would have permanently encoded flat's irregularity in the snapshot, so `schema_0` translates flat into the normalised shape
|
|
39
|
+
instead. Measured: every EVSE identity field reads identically on both adapters, so for that device class identity stops being a migration delta at all. **`battery.model` changes value for existing flat users at this upgrade** — it gains the designation
|
|
40
|
+
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.
|
|
41
|
+
- **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.
|
|
42
|
+
- **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
|
|
43
|
+
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
|
|
44
|
+
produces plausible but wrong power and energy figures.
|
|
45
|
+
- **`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`
|
|
46
|
+
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.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
#### Adapter architecture
|
|
51
|
+
|
|
52
|
+
- **The `SchemaAdapter` protocol, and `ADAPTER_CONTRACT` alongside it.** Member presence is not the whole contract — a Protocol cannot express signatures at runtime, so an adapter carrying every required name and the wrong `__init__` arity would pass
|
|
53
|
+
discovery and fail much later inside the transport, as a bare `TypeError` about an argument count. Every adapter declares `ADAPTER_CONTRACT` as a **literal** and discovery rejects anything that does not match this package's `ADAPTER_CONTRACT_VERSION`; a
|
|
54
|
+
value read from the installed bootstrap would agree with every bootstrap, which is the disagreement being looked for. The required-member set is derived from every public member the protocol declares, not only the callable ones.
|
|
55
|
+
- **`installed_adapter_keys()` and `SpanMqttClient.installed_adapters`.** Enumeration reads distribution metadata only; an adapter is imported the first time a panel asks for that key. A flat panel therefore never imports `schema_1`, and with it never
|
|
56
|
+
imports the eBus SDK or jsonschema, for a parser it would not call. The async paths run both in a thread, and resolution stays cached per key, which is what keeps the synchronous pre-rebuild callback free of I/O.
|
|
57
|
+
- **`resolve_adapter(key, reason)`** — the single place a missing adapter becomes a named error, used by both dispatch and the transport's default path.
|
|
58
|
+
- **`span_panel_api.dispatch.select_adapter_key`**, so the transport can dispatch without importing the factory. `adapters.py` answers "what is installed"; `dispatch.py` answers "what does this panel need".
|
|
59
|
+
- **`SpanPanelAdapterMissingError`, `SpanPanelSchemaVersionError` and `SpanPanelAdapterIncompatibleError`**, all exported from the top-level package. The three are separate because the remedy differs: missing means install something, a schema version no
|
|
60
|
+
adapter can even be named for means there is nothing to install yet, and incompatible means installing more cannot help. Reporting the third as the first sends someone to install a package they already have. Discovery only _logs_ a rejection, so one
|
|
61
|
+
unusable third-party adapter cannot take down a panel whose own adapter is fine; the error surfaces only when the rejected adapter turns out to be the one required.
|
|
62
|
+
- **`SpanMqttClient(adapter_factory=...)` is optional.** When omitted the parser is resolved through entry-point discovery at `_build_adapter()`. Resolution is lazy by design: constructing a client must not require an adapter to be installed, only building
|
|
63
|
+
a parser must. Dispatch happens wherever a parser is built, so a directly constructed client dispatches exactly as the factory path does.
|
|
64
|
+
- **`V2HomieSchema.data_model_version`**, carrying the `dataModelVersion` field and `None` when the panel omits it. Absence is the flat signal and stays distinct from an empty string.
|
|
65
|
+
|
|
66
|
+
#### Surviving a firmware upgrade
|
|
67
|
+
|
|
68
|
+
- **A panel that changes schema generation mid-life is redispatched rather than reloaded.** The schema is refetched over REST and the parser swapped in place, so an install that upgrades from flat to parent/child keeps running. The new adapter is resolved
|
|
69
|
+
**before** any state is touched, so a flat-only install that meets a parent/child panel logs which package is missing and keeps the parser it has instead of raising into a background task.
|
|
70
|
+
- **The wait for a panel to finish rebooting does not give up.** Any bound here is sized against a reboot somebody measured, and the next reboot is not that reboot — a live firmware upgrade has been observed taking four minutes from MQTT dropping to the
|
|
71
|
+
broker returning, still answering `502` at that point. Giving up has nothing to recommend it: the only things that start another attempt are the reconnect edge and the panel republishing its data-model version, and a panel that finishes booting after the
|
|
72
|
+
wait expired produces neither, so running out of attempts means stranded until somebody reloads by hand.
|
|
73
|
+
- **The retry interval settles at thirty seconds rather than growing.** Backing off without a ceiling would mean a panel that took a while to return was then ignored for longer than it took. The gap goes 1, 2, 4, 8, 16, 30 and stays there, so once your
|
|
74
|
+
panel is answering it is noticed within half a minute however long the wait has already run. Waiting costs nothing you were relying on — energy sensors hold their last reading through an outage on their own grace period, which is untouched by this — and
|
|
75
|
+
what is left is one request every thirty seconds to a device on your own network.
|
|
76
|
+
- **Nothing escapes the redispatch task.** An unexpected failure there used to surface as a bare `Task exception was never retrieved` while the parser silently stayed on the old generation. It is logged at ERROR naming the consequence and the remedy,
|
|
77
|
+
because a reload is the user's only move and nothing else was going to tell them.
|
|
78
|
+
|
|
79
|
+
#### Injected HTTP client on the runtime path
|
|
80
|
+
|
|
81
|
+
- **`SpanMqttClient` accepts an `httpx_client`, and so does `create_span_client`.** Four config-flow-facing entry points already took an injected client; the runtime path was the one that did not, so every schema read built a throwaway — including the
|
|
82
|
+
retry loop that runs during a firmware upgrade, which built one per attempt at exactly the moment the panel was mid-reboot. Optional and defaulted, so nothing outside Home Assistant changes. The ownership rule is the one the existing entry points already
|
|
83
|
+
state: a client handed in is never closed here, and its timeouts, limits and headers are the caller's, which is why the per-call `timeout` defaults are ignored when one is given.
|
|
84
|
+
|
|
85
|
+
#### Reference payloads shipped in the wheel
|
|
86
|
+
|
|
87
|
+
- **`span_panel_api.reference_payloads`, shipping `homie_schema.json` as package data.** The captured `GET /api/v2/homie/schema` response is reached by `homie_schema()` and `homie_schema_types()` rather than by path. It was already being consumed outside
|
|
88
|
+
this repository — the Home Assistant integration checks the field paths it declares against what an adapter can actually produce — by vendoring a byte copy with a README explaining where the copy came from. A copy has no version: it goes stale in
|
|
89
|
+
silence, and a stale one turns the integration's conformance gate into a check against a schema no panel runs. Shipped, the payload carries the version of the release it came with. `homie_schema_types()` returns `HomieSchemaTypes`, precisely what
|
|
90
|
+
`span_panel_api_schema_0.field_metadata.build_field_metadata` accepts, so a caller building metadata never reaches into an untyped document to get it. The parent/child device tree is the other half and ships from `span-panel-api-schema-1`, with the
|
|
91
|
+
parser that can interpret it.
|
|
92
|
+
|
|
93
|
+
#### New snapshot surface
|
|
94
|
+
|
|
95
|
+
Everything below is additive. Each field is `None` or empty on a panel that publishes no such thing, and no flat panel publishes any of it unless stated.
|
|
96
|
+
|
|
97
|
+
- **`SpanMidSnapshot` and `SpanPanelSnapshot.mid`.** The parent/child model puts the `grid` capability on a Microgrid Interconnect Device rather than on the enclosure, so islanding state, grid state and the grid-forming entity live there. Presence is
|
|
98
|
+
`snapshot.mid is not None` rather than a sentinel field, and identity is `info/serial-number` rather than the Homie device id, which the proxy model warns is not stable across a proxy-to-native transition.
|
|
99
|
+
- **`dsm_state` and `current_run_config` are read from the MID.** Both are existing entities that would otherwise degrade to `UNKNOWN` on a parent/child panel: `schema_0` _derives_ them from a multi-signal heuristic, and the parent/child model states the
|
|
100
|
+
answer outright. Sensed from a ready MID, falling back to the user's `shed/asserted-islanding-state` when it is not ready, then to a `power-flows/grid` heuristic when there is no MID at all, and unknown otherwise. A missing MID never reports on-grid — it
|
|
101
|
+
means SPAN is not the islanding authority, not that the site is on grid, and a generator-fed island is the counterexample. `PANEL_BACKUP` versus `PANEL_OFF_GRID` becomes authoritative rather than guessed.
|
|
102
|
+
- **`grid_islandable` is mapped to `grid-forming/capable`** over the BESS's inverter children, as the disjunction — a panel does not island, its DER does, and flat expressed a property of the DER as a property of the enclosure. It returns `None` rather
|
|
103
|
+
than `False` when nothing publishes it, so absence stays a gap instead of becoming a claim. No producer publishes it today, which is recorded rather than worked around.
|
|
104
|
+
- **`SpanPanelSnapshot.lugs_at_service_entrance`, saying whether this enclosure's upstream lugs are the utility connection point.** `instant_grid_power_w` is those lugs' `meter/active-power`, and the name holds only at the service entrance: a BESS wired
|
|
105
|
+
ahead of the main lugs, or an enclosure fed by another enclosure, leaves the lugs metering panel-side flow while the utility side differs by whatever that device contributes or absorbs. `power_flow_grid` stays site-level and correct in both, so the two
|
|
106
|
+
legitimately disagree — and before this a consumer seeing them disagree could not tell a topology from a fault. Sourced from the lugs' `connection/fed-by-device-id`, which `power-flows` 0.3 names as the detection mechanism when it qualifies its own
|
|
107
|
+
negation table. Defaults `True`, because flat firmware predates chaining and a flat panel's lugs really are its service entrance.
|
|
108
|
+
- **`SpanBatterySnapshot.power_w` and `SpanBatterySnapshot.communication_state`.** The battery device has always published `meter/active-power` and `status/communication-state` and neither reached a field, so a consumer could show the enclosure's
|
|
109
|
+
arbitrated `power_flow_battery` and nothing the BESS itself reports. `power_w` is **discharge-positive**: the enclosure meters the BESS the way it meters a circuit it feeds, so positive means power flowing _out of_ the battery, matching the eBus rule for
|
|
110
|
+
a device's own meter. The asymmetry with `panel.power_flow_battery` is deliberate — the enclosure's arbitrated figure is passed through untouched by both adapters and is charge-positive, so it reads negative for the same discharging battery that makes
|
|
111
|
+
`power_w` positive. The two describe the same physical power in opposite frames, and a consumer rendering both negates one of them. `communication_state` stays the published enum string (`OK`/`DEGRADED`/`LOST`/`UNKNOWN`) rather than collapsing to a bool,
|
|
112
|
+
because `DEGRADED` is neither `OK` nor `LOST`; it is deliberately not merged into `battery.connected`, which is the _enclosure's_ view of the same link.
|
|
113
|
+
- **`SpanEvseSnapshot.connected` and `SpanPVSnapshot.connected`.** `battery.connected` has carried the enclosure's view of the link to the BESS from the upstream lugs' `connection/fed-by-device-status`; the other half of the same capability — a circuit's
|
|
114
|
+
`connection/feeds-device-status` — reached nothing, so only one of a panel's three DER classes had a link-health field. `None` is the specification's "unknown" and is load-bearing: the enum is `OK,LOST,DEGRADED` with no `UNKNOWN` member, and a mixed-load
|
|
115
|
+
or unsurveyed circuit publishes no connection record at all, which is the normal state for most of a panel's circuits. So absence is never a fault. `DEGRADED` collapses to `False`, because the question this field answers is whether the enclosure can talk
|
|
116
|
+
to the device. The charger's link is not the charger's session: `evse.status` is the OCPP-style state the charger reports about the cable in front of it, and a charger mid-session over a lost link publishes `CHARGING` and `connected=False` at once.
|
|
117
|
+
- **Five `shed-forecast` fields**: `shed_time_to_priority_shed_min`, `shed_total_time_remaining_min`, `shed_full_charge_time_to_priority_shed_min`, `shed_full_charge_total_time_remaining_min` and `shed_forecast_confidence`. The backup-planning numbers —
|
|
118
|
+
how long before my battery starts shedding circuits, how long before it is exhausted — were on the wire and stopped at the transport. All four times are `integer` minutes as the capability declares, parsed so that a publisher serialising a whole number
|
|
119
|
+
with a decimal point still resolves; `confidence` stays the raw `LOW`/`MEDIUM`/`HIGH` string, because it qualifies the four times rather than standing alone. `None` is load-bearing here too: zero minutes is a legitimate reading — shedding starts now — so
|
|
120
|
+
a defaulted zero would be indistinguishable from the worst forecast the capability can report.
|
|
121
|
+
- **`SpanPanelSnapshot.adopted_devices`, reporting a device type this library models nothing for rather than dropping it.** The schema is explicitly vendor-extensible, so an unmodelled device is an expected arrival rather than a hypothetical one; before
|
|
122
|
+
this it produced no field, no metadata row and no sign it was there. `AdoptedDevice` carries the device's identity and its readings. **The unit is a device, never a property**: a new property on a device already modelled is a curation task with a short
|
|
123
|
+
turnaround, and surfacing it automatically would spend a consumer's entity identity permanently on a shape a human would likely have chosen differently. An unmodelled _type_ is the opposite case — no curation is coming, so silence is the only
|
|
124
|
+
alternative. Extra instances of a modelled type are deliberately not adopted either: a second BESS is a multiplicity limit, not an unmodelled device.
|
|
125
|
+
- **`AdoptedDevice.parent` and `AdoptedDevice.proxied`**, carrying the proxy link a device declares. Carried rather than acted on — an adopted device is still registered under the enclosure — because a _proxied_ unmodelled device is a real shape that would
|
|
126
|
+
otherwise be flattened away unrecorded. The nesting is deliberately not built: proxied ids differ by design and consumers correlate by `info/serial-number` rather than by device id, and the tree model is being reshaped upstream, so the fields capture the
|
|
127
|
+
evidence and the topology waits.
|
|
128
|
+
- **`AdoptedProperty.set_topic`, `SpanMqttClient.set_adopted_property` and `AdoptedControlProtocol`**, so a settable property on an adopted device can be written and the write cannot reach anything else. The topic is populated only for a settable property
|
|
129
|
+
on a device `is_modelled` rejects, so it is the scoping that authorises the write rather than a check a caller has to remember: the transport resolves the property against the current snapshot's `adopted_devices` and publishes to the topic that property
|
|
130
|
+
carries, no topic is accepted from the caller, and a device this library models produces no `AdoptedDevice` to find. There is deliberately no translation and no bounds check on an adopted write — both exist on curated controls because this library knows
|
|
131
|
+
what those properties mean, and inventing a bound for somebody else's hardware would be inventing a fact. `AdoptedControlProtocol` lets a consumer ask `isinstance` before offering the control, exactly as it does for circuit, panel and EVSE control.
|
|
132
|
+
- **`SpanPanelSnapshot.extension_properties`, `ExtensionProperty` and `ExtensionSubject`**, so a vendor property on a device this library _already_ models reaches a consumer instead of stopping at diagnostics. Adoption covers the unmodelled-device half;
|
|
133
|
+
this covers the other one, where a new property on the BESS, a charger, a circuit or the panel would otherwise be a declaration with no value, visible only to a maintainer reading a diagnostics attachment. The subject names which modelled snapshot
|
|
134
|
+
subject a property hangs off — `battery`, `mid`, `pv`, `panel`, `lugs` with `upstream`/`downstream`, and `evse`/`circuit` with the instance key the snapshot's own maps use — so a consumer resolves the device with a lookup it already performs. What is
|
|
135
|
+
_not_ exposed is the field-level mapping: the subject is one value per device and cannot drift, while the wire-property-to-snapshot-field map is the adapter's internal business and exporting it would freeze it as API.
|
|
136
|
+
- **An extension property's value never reaches diagnostics, structurally.** `ExtensionProperty` is deliberately not a `FieldMetadata`, so it cannot enter the map `partition()` walks and has no path into a payload that leaves the machine. The discovery
|
|
137
|
+
rows keep flowing unchanged: the same property appears in both surfaces on purpose, joined by its `{node}/{property}` path — a declaration for the maintainer, a reading for the user. It is read-only by construction: it carries `settable` for curation
|
|
138
|
+
triage and no set topic, and there is no member a write path could be built from.
|
|
139
|
+
|
|
140
|
+
### Fixed
|
|
141
|
+
|
|
142
|
+
- **A single HTTP 429 from the panel no longer aborts setup.** The panel rate-limits `GET /api/v2/certificate/ca` at roughly seven requests a second, and `download_ca_cert()` raised on any non-200 — so a reconnect storm, or simply a second client polling
|
|
143
|
+
the same panel, turned a transient condition into a hard failure that needed a manual reload. It now retries a 429 with exponential backoff, honouring `Retry-After` when the panel sends it and falling back to the backoff curve when the header is absent
|
|
144
|
+
or malformed. Non-429 responses still fail fast. `max_attempts` and `backoff_s` are parameters, so a caller can tune the behaviour or switch it off. Reported and fixed by [@brunocramos](https://github.com/brunocramos) in
|
|
145
|
+
[#148](https://github.com/SpanPanel/span-panel-api/pull/148).
|
|
146
|
+
|
|
7
147
|
## [2.6.4] - 05/2026
|
|
8
148
|
|
|
9
149
|
### Fixed
|
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: A client library for SPAN Panel API
|
|
5
5
|
Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
|
|
6
6
|
Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
|
|
7
7
|
Author: SpanPanel
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
10
|
-
Requires-Python: <4.0,>=3.
|
|
11
|
-
Requires-Dist: httpx
|
|
10
|
+
Requires-Python: <4.0,>=3.14
|
|
11
|
+
Requires-Dist: httpx<1.0,>=0.28.1
|
|
12
12
|
Requires-Dist: paho-mqtt<3.0.0,>=2.0.0
|
|
13
13
|
Requires-Dist: pyyaml>=6.0.0
|
|
14
|
+
Provides-Extra: schema-0
|
|
15
|
+
Requires-Dist: span-panel-api-schema-0>=1.0.0; extra == 'schema-0'
|
|
16
|
+
Provides-Extra: schema-1
|
|
17
|
+
Requires-Dist: span-panel-api-schema-1>=1.0.0; extra == 'schema-1'
|
|
14
18
|
Description-Content-Type: text/markdown
|
|
15
19
|
|
|
16
20
|
# SPAN Panel API
|
|
@@ -38,10 +42,40 @@ A Python client library for the SPAN Panel v2 API, using MQTT/Homie for real-tim
|
|
|
38
42
|
|
|
39
43
|
## Installation
|
|
40
44
|
|
|
45
|
+
Two packages: the transport, and a parser for your panel's schema. `span-panel-api` contains **no parser** — installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`.
|
|
46
|
+
|
|
41
47
|
```bash
|
|
42
|
-
|
|
48
|
+
# flat schema, firmware r202603-r202627
|
|
49
|
+
pip install "span-panel-api[schema-0]"
|
|
50
|
+
|
|
51
|
+
# parent/child schema, firmware r202633+ (data-model-version 1.x)
|
|
52
|
+
pip install "span-panel-api[schema-1]"
|
|
53
|
+
|
|
54
|
+
# support either panel from one install
|
|
55
|
+
pip install "span-panel-api[schema-0,schema-1]"
|
|
43
56
|
```
|
|
44
57
|
|
|
58
|
+
The extras are the recommended spelling because they give `pip install -U` a correct upgrade path; naming `span-panel-api-schema-0` / `span-panel-api-schema-1` directly works too.
|
|
59
|
+
|
|
60
|
+
### The parser is hot-loaded, not imported
|
|
61
|
+
|
|
62
|
+
`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:
|
|
63
|
+
|
|
64
|
+
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
|
|
65
|
+
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.
|
|
66
|
+
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.
|
|
67
|
+
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.
|
|
68
|
+
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
|
|
69
|
+
express signatures at runtime — so this is what stops two packages built against different versions of each other failing much later as a bare `TypeError` inside the transport. A rejection is logged rather than raised, so one unusable third-party
|
|
70
|
+
adapter cannot take down a panel whose own adapter is fine.
|
|
71
|
+
5. **Re-dispatch when the panel changes underneath you.** A panel that upgrades firmware from flat to parent/child mid-life drops MQTT, reboots and comes back on a new schema. The client refetches, resolves the new adapter **before** touching any state,
|
|
72
|
+
and swaps the parser in place — no reload. An install with no adapter for the new generation logs which package to install and keeps the parser it has.
|
|
73
|
+
|
|
74
|
+
Three errors keep the failure modes apart, because the remedy differs: `SpanPanelAdapterMissingError` (install something), `SpanPanelSchemaVersionError` (a schema no adapter can even be named for), and `SpanPanelAdapterIncompatibleError` (installing more
|
|
75
|
+
cannot help). All are exported from the top-level package.
|
|
76
|
+
|
|
77
|
+
The consequence worth planning around: **supporting a new panel schema is an install, not an upgrade.** The distributions version independently — see [RELEASE.md](RELEASE.md).
|
|
78
|
+
|
|
45
79
|
### Dependencies
|
|
46
80
|
|
|
47
81
|
- `httpx` — v2 authentication and detection endpoints
|
|
@@ -52,10 +86,15 @@ pip install span-panel-api
|
|
|
52
86
|
|
|
53
87
|
### Transport
|
|
54
88
|
|
|
55
|
-
The `SpanMqttClient` connects to the panel's MQTT broker (MQTTS or WebSocket) and subscribes to the Homie device tree.
|
|
89
|
+
The `SpanMqttClient` connects to the panel's MQTT broker (MQTTS or WebSocket) and subscribes to the Homie device tree. It owns the connection, the subscription and the dispatch decision — and nothing else. Everything that knows what a topic _means_ lives
|
|
90
|
+
in the adapter for that panel's schema:
|
|
91
|
+
|
|
92
|
+
- **The transport** (this package) makes one wildcard subscription, routes messages, tracks connection state, publishes commands, and hands raw messages to whichever parser was resolved for this panel.
|
|
93
|
+
- **The parser** (`span-panel-api-schema-0` or `span-panel-api-schema-1`) accumulates properties, decides when the panel is ready to read, and builds typed `SpanPanelSnapshot` dataclasses from what it has.
|
|
56
94
|
|
|
57
|
-
|
|
58
|
-
|
|
95
|
+
That boundary is why `HomiePropertyAccumulator`, `HomieLifecycle` and `HomieDeviceConsumer` are **not** exported from this package: all three are flat-schema-specific rather than Homie-convention-level. The accumulator filters every topic against a single
|
|
96
|
+
device's prefix and stores `node → prop`, which drops nearly every message under the parent/child model, and `HomieLifecycle`'s members are not Homie 5 `$state` values but a consumer-side progression encoding "one description received ⇒ ready". They live
|
|
97
|
+
in `span_panel_api_schema_0`, where that model is correct. The parent/child parser reaches the same result differently, replaying the retained tree through the eBus SDK and waiting for every declared device to describe itself at any depth.
|
|
59
98
|
|
|
60
99
|
Changes are pushed to consumers via callbacks. Dirty-node tracking allows the snapshot builder to skip unchanged nodes, reducing per-scan CPU cost on constrained hardware.
|
|
61
100
|
|
|
@@ -76,7 +115,7 @@ This means the library can be dropped into any asyncio application — including
|
|
|
76
115
|
|
|
77
116
|
Circuit names arrive as MQTT retained messages that may land after the Homie device transitions to `$state=ready`. The client handles this with a bounded wait during `connect()`:
|
|
78
117
|
|
|
79
|
-
1. After the device reaches ready state, the client polls `
|
|
118
|
+
1. After the device reaches ready state, the client polls the resolved adapter's `circuit_nodes_missing_names()` every 250ms — a `SchemaAdapter` member, so both parsers answer it in their own terms.
|
|
80
119
|
2. As retained name properties arrive, the consumer stores them. Once all circuit-type nodes have a name, the wait returns immediately.
|
|
81
120
|
3. If names have not all arrived within 10 seconds, the timeout expires (non-fatal) and the client proceeds — circuits without names will use fallback identifiers.
|
|
82
121
|
|
|
@@ -84,28 +123,44 @@ This ensures that the first `get_snapshot()` after connect returns human-readabl
|
|
|
84
123
|
|
|
85
124
|
### Protocols
|
|
86
125
|
|
|
87
|
-
The library defines
|
|
126
|
+
The library defines structural subtyping protocols (PEP 544). All are `runtime_checkable`, so a consumer asks `isinstance` before offering a control rather than assuming the panel in front of it supports one:
|
|
88
127
|
|
|
89
128
|
| Protocol | Purpose |
|
|
90
129
|
| -------------------------- | ------------------------------------------------------------------------------------------ |
|
|
91
130
|
| `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot`, `register_connection_callback` |
|
|
92
131
|
| `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` |
|
|
93
132
|
| `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` |
|
|
133
|
+
| `EvseControlProtocol` | Per-charger control: `set_evse_charge_limit(node_id, amps)` |
|
|
134
|
+
| `AdoptedControlProtocol` | Write to a settable property of a device this library models nothing for |
|
|
94
135
|
| `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` |
|
|
95
136
|
|
|
96
|
-
|
|
137
|
+
The first five differ in subject, not just in name. `EvseControlProtocol` is separate from `PanelControlProtocol` because several chargers may be commissioned at once and every call names which one. `AdoptedControlProtocol` differs in kind: the curated
|
|
138
|
+
setters name a control this library understands and translate or bound the value on the way out, while this one names a property by its wire address and passes the value through, because the declaration is all anybody here knows about it. That write is
|
|
139
|
+
authorised by the snapshot rather than by its arguments — the transport resolves the property against the current `adopted_devices` and refuses anything it does not find carrying a set topic, so a device this library _does_ model cannot be addressed
|
|
140
|
+
through it.
|
|
141
|
+
|
|
142
|
+
A seventh protocol, `SchemaAdapter`, is the bootstrap-to-parser contract rather than a consumer-facing one; it is what an adapter distribution implements and what discovery checks. Integration code programs against the protocols above, not against
|
|
143
|
+
transport-specific classes.
|
|
97
144
|
|
|
98
145
|
### Snapshots
|
|
99
146
|
|
|
100
147
|
All panel state is represented as immutable, frozen dataclasses:
|
|
101
148
|
|
|
102
|
-
| Dataclass | Content
|
|
103
|
-
| --------------------- |
|
|
104
|
-
| `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, circuits, battery, PV, EVSE |
|
|
105
|
-
| `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current, `$target` pending state
|
|
106
|
-
| `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh,
|
|
107
|
-
| `SpanPVSnapshot` | PV inverter:
|
|
108
|
-
| `SpanEvseSnapshot` | EVSE (EV charger): status, lock state, advertised current,
|
|
149
|
+
| Dataclass | Content |
|
|
150
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
151
|
+
| `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, shed forecast, circuits, battery, PV, EVSE, MID |
|
|
152
|
+
| `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current, `$target` pending state |
|
|
153
|
+
| `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh, own meter reading, communication state, link health, `model` / `part_number`, nameplate capacity |
|
|
154
|
+
| `SpanPVSnapshot` | PV inverter: link health, `model` / `part_number`, nameplate capacity |
|
|
155
|
+
| `SpanEvseSnapshot` | EVSE (EV charger): status, lock state, advertised current, link health, `model` / `part_number` / serial / version metadata |
|
|
156
|
+
| `SpanMidSnapshot` | Microgrid Interconnect Device: islanding state, grid state, grid-forming entity |
|
|
157
|
+
| `AdoptedDevice` | A device type this library models nothing for, carried whole: identity, readings, proxy link |
|
|
158
|
+
| `ExtensionProperty` | A vendor property on a device this library _does_ model, with its value and the subject it hangs off |
|
|
159
|
+
|
|
160
|
+
Identity is normalised across every DER class: **`model` is the human designation and `part_number` is the SKU**, on `battery`, `evse` and `pv` alike. `product_name` was retired in 3.0.0 — see the changelog, because `battery.model` changes value for
|
|
161
|
+
existing flat users at that upgrade.
|
|
162
|
+
|
|
163
|
+
`mid`, `adopted_devices`, `extension_properties` and the per-DER link-health fields exist only under the parent/child schema. They are `None` or empty on a flat panel rather than absent, so a consumer reads the same snapshot type either way.
|
|
109
164
|
|
|
110
165
|
## Usage
|
|
111
166
|
|
|
@@ -128,7 +183,15 @@ async def main():
|
|
|
128
183
|
|
|
129
184
|
# Get a point-in-time snapshot
|
|
130
185
|
snapshot = await client.get_snapshot()
|
|
131
|
-
|
|
186
|
+
# The upstream lugs' own meter. That is grid flow only where the lugs are
|
|
187
|
+
# the utility connection point; a BESS wired ahead of them, or a panel fed
|
|
188
|
+
# by another panel, makes it this panel's feed instead. `power_flow_grid`
|
|
189
|
+
# is the site-level figure in every topology.
|
|
190
|
+
if snapshot.lugs_at_service_entrance:
|
|
191
|
+
print(f"Grid power: {snapshot.instant_grid_power_w}W")
|
|
192
|
+
else:
|
|
193
|
+
print(f"Panel feed: {snapshot.instant_grid_power_w}W")
|
|
194
|
+
print(f"Grid power: {snapshot.power_flow_grid}W")
|
|
132
195
|
print(f"Firmware: {snapshot.firmware_version}")
|
|
133
196
|
print(f"Circuits: {len(snapshot.circuits)}")
|
|
134
197
|
|
|
@@ -346,10 +409,21 @@ All exceptions inherit from `SpanPanelError`:
|
|
|
346
409
|
| `SpanPanelTimeoutError` | Request or connection timed out |
|
|
347
410
|
| `SpanPanelValidationError` | Data validation failure |
|
|
348
411
|
| `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
|
|
349
|
-
| `SpanPanelServerError` | Panel
|
|
412
|
+
| `SpanPanelServerError` | Panel answered 5xx, or answered `200` with a body that cannot be used — "not ready yet" |
|
|
413
|
+
|
|
414
|
+
Three more are specific to the hot-loading model, and they are separate because the remedy differs:
|
|
415
|
+
|
|
416
|
+
| Exception | Cause | Remedy |
|
|
417
|
+
| ----------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------- |
|
|
418
|
+
| `SpanPanelAdapterMissingError` | Known schema, no installed parser for it | Install the named package |
|
|
419
|
+
| `SpanPanelSchemaVersionError` | The panel reports a `data-model-version` no adapter can even be named for | Nothing to install yet — report the value |
|
|
420
|
+
| `SpanPanelAdapterIncompatibleError` | The required adapter is installed but was built against another contract | Installing more cannot help — align versions |
|
|
421
|
+
|
|
422
|
+
Reporting the third as the first would send someone to install a package they already have.
|
|
350
423
|
|
|
351
424
|
`SpanPanelStaleDataError` is distinct from `SpanPanelConnectionError`: the former means the client is running but data cannot be trusted right now (transient disconnect, or panel-declared not-ready); the latter means the initial connect failed and the
|
|
352
|
-
client cannot be used at all.
|
|
425
|
+
client cannot be used at all. `SpanPanelServerError` covers the whole 5xx class deliberately: a booting panel brings its network stack and reverse proxy up before the application behind them, so it _answers_ rather than refuses, and that has to be
|
|
426
|
+
distinguishable from a 4xx that will not fix itself on its own.
|
|
353
427
|
|
|
354
428
|
```python
|
|
355
429
|
from span_panel_api import (
|
|
@@ -385,28 +459,67 @@ The `PanelCapability` flag enum advertises transport features at runtime:
|
|
|
385
459
|
| `CIRCUIT_CONTROL` | Can set relay state and shed priority |
|
|
386
460
|
| `BATTERY_SOE` | Battery state-of-energy available |
|
|
387
461
|
|
|
462
|
+
## Reference Payloads
|
|
463
|
+
|
|
464
|
+
Captures of what a panel actually serves, shipped as package data so a consumer can check its own assumptions against real bytes without vendoring a copy that silently goes stale:
|
|
465
|
+
|
|
466
|
+
```python
|
|
467
|
+
from span_panel_api.reference_payloads import homie_schema, homie_schema_types
|
|
468
|
+
|
|
469
|
+
document = homie_schema() # the captured GET /api/v2/homie/schema response
|
|
470
|
+
types = homie_schema_types() # its `types` map, typed as HomieSchemaTypes
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
`homie_schema_types()` returns exactly what `span_panel_api_schema_0.field_metadata.build_field_metadata` accepts, so building real adapter metadata to compare against is two lines and no file handling.
|
|
474
|
+
|
|
475
|
+
The parent/child device tree is the schema_1 counterpart and ships from that adapter, with the parser that can interpret it:
|
|
476
|
+
|
|
477
|
+
```python
|
|
478
|
+
from span_panel_api_schema_1.reference_payloads import devices_from_tree, parent_child_tree
|
|
479
|
+
|
|
480
|
+
devices = devices_from_tree(parent_child_tree())
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
Each payload carries the version of the release it shipped in. Pin a version and you read the bytes that version was written against.
|
|
484
|
+
|
|
388
485
|
## Project Structure
|
|
389
486
|
|
|
487
|
+
One repository, three distributions. The bootstrap is at the root; each parser is a workspace member under `packages/`, published separately and versioned on its own axis.
|
|
488
|
+
|
|
390
489
|
```text
|
|
391
|
-
src/span_panel_api/
|
|
392
|
-
├── __init__.py
|
|
393
|
-
├──
|
|
394
|
-
├──
|
|
395
|
-
├──
|
|
396
|
-
├──
|
|
397
|
-
├──
|
|
398
|
-
├──
|
|
399
|
-
├──
|
|
400
|
-
├──
|
|
490
|
+
src/span_panel_api/ # distribution: span-panel-api (no parser)
|
|
491
|
+
├── __init__.py # Public API exports
|
|
492
|
+
├── _http.py # Shared httpx plumbing / client ownership rules
|
|
493
|
+
├── adapters.py # installed_adapter_keys(), resolve_adapter() — metadata, then lazy import
|
|
494
|
+
├── auth.py # v2 HTTP provisioning (register, cert, schema, passphrase)
|
|
495
|
+
├── const.py # Panel state constants (DSM, relay)
|
|
496
|
+
├── detection.py # detect_api_version() → DetectionResult
|
|
497
|
+
├── dispatch.py # select_adapter_key() — what does this panel need?
|
|
498
|
+
├── exceptions.py # Exception hierarchy
|
|
499
|
+
├── factory.py # create_span_client() → SpanMqttClient
|
|
500
|
+
├── models.py # Snapshot dataclasses (panel, circuit, battery, PV, EVSE, MID, adopted)
|
|
501
|
+
├── phase_validation.py # Electrical phase utilities
|
|
502
|
+
├── protocol.py # PEP 544 protocols, SchemaAdapter, PanelCapability flags
|
|
503
|
+
├── schema_drift.py # Reporting a panel that outruns what we can read
|
|
504
|
+
├── reference_payloads/ # Captured GET /api/v2/homie/schema, shipped as package data
|
|
401
505
|
└── mqtt/
|
|
402
506
|
├── __init__.py
|
|
403
|
-
├──
|
|
404
|
-
├──
|
|
405
|
-
├──
|
|
406
|
-
├──
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
507
|
+
├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern)
|
|
508
|
+
├── client.py # SpanMqttClient (transport + control protocols)
|
|
509
|
+
├── connection.py # AsyncMqttBridge (event-loop-driven, no threads)
|
|
510
|
+
├── const.py # MQTT/Homie constants + UUID helpers
|
|
511
|
+
└── models.py # MqttClientConfig, MqttTransport
|
|
512
|
+
|
|
513
|
+
packages/schema-0/ # distribution: span-panel-api-schema-0
|
|
514
|
+
└── src/span_panel_api_schema_0/
|
|
515
|
+
# Flat parser: HomiePropertyAccumulator, HomieLifecycle,
|
|
516
|
+
# HomieDeviceConsumer, field metadata, SCHEMA_ANCHOR
|
|
517
|
+
|
|
518
|
+
packages/schema-1/ # distribution: span-panel-api-schema-1
|
|
519
|
+
├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed
|
|
520
|
+
└── src/span_panel_api_schema_1/
|
|
521
|
+
# Parent/child parser: ControllerRoutes, snapshot mapper,
|
|
522
|
+
# adoption, catalog validator, spec_lock.json, reference payloads
|
|
410
523
|
```
|
|
411
524
|
|
|
412
525
|
## Development
|