span-panel-api 3.0.1__tar.gz → 3.1.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.1 → span_panel_api-3.1.0}/CHANGELOG.md +169 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/PKG-INFO +86 -34
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/README.md +83 -31
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/pyproject.toml +8 -3
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/__init__.py +35 -1
- span_panel_api-3.1.0/src/span_panel_api/_http.py +124 -0
- span_panel_api-3.1.0/src/span_panel_api/_ssl.py +104 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/adapters.py +17 -2
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/auth.py +176 -33
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/detection.py +11 -4
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/exceptions.py +37 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/factory.py +26 -6
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/models.py +62 -10
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/mqtt/__init__.py +6 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/mqtt/client.py +602 -36
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/mqtt/connection.py +366 -51
- span_panel_api-3.1.0/src/span_panel_api/mqtt/control.py +193 -0
- span_panel_api-3.1.0/src/span_panel_api/mqtt/models.py +51 -0
- span_panel_api-3.1.0/src/span_panel_api/protocol.py +353 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/conftest.py +71 -3
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -32
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/fixtures/v2/README.md +2 -2
- span_panel_api-3.1.0/tests/reference_payloads/README.md +67 -0
- span_panel_api-3.1.0/tests/reference_payloads/__init__.py +29 -0
- span_panel_api-3.1.0/tests/reference_payloads/bootstrap.py +48 -0
- span_panel_api-3.1.0/tests/reference_payloads/parent_child_tree.json +234 -0
- span_panel_api-3.1.0/tests/reference_payloads/schema_one.py +78 -0
- span_panel_api-3.1.0/tests/test_absent_readings_are_not_zero.py +293 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_adapters_discovery.py +23 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_adopted_control.py +17 -5
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_adoption.py +1 -1
- span_panel_api-3.1.0/tests/test_auth_redaction.py +127 -0
- span_panel_api-3.1.0/tests/test_ca_pinning.py +458 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_catalog_divergence.py +2 -2
- span_panel_api-3.1.0/tests/test_control_interceptor.py +469 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_detection_auth.py +1 -1
- span_panel_api-3.1.0/tests/test_https_transport.py +252 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_mqtt_client_connection.py +3 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_mqtt_connect_flow.py +25 -9
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_mqtt_homie.py +99 -24
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_packaging.py +44 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_protocol_conformance.py +5 -4
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_public_api_unchanged.py +27 -0
- span_panel_api-3.1.0/tests/test_publish_outcome.py +438 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_redispatch_on_reconnect.py +13 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_reference_tree_values.py +25 -15
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_adapter.py +11 -4
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_against_simulator.py +2 -2
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_charge_limit.py +21 -12
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_circuits.py +66 -7
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_conformance.py +117 -15
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_connection_health.py +5 -5
- span_panel_api-3.1.0/tests/test_schema_one_control_refusal.py +380 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_devices.py +22 -12
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_discovery.py +10 -7
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_extension.py +1 -1
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_panel.py +24 -12
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_pcs.py +4 -4
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_service_entrance.py +5 -5
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_shed_forecast.py +3 -3
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_snapshot.py +5 -3
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_provenance.py +1 -1
- span_panel_api-3.1.0/tests/test_schema_zero_adapter.py +223 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_ssl_context.py +9 -9
- span_panel_api-3.0.1/src/span_panel_api/_http.py +0 -66
- span_panel_api-3.0.1/src/span_panel_api/mqtt/models.py +0 -35
- span_panel_api-3.0.1/src/span_panel_api/protocol.py +0 -224
- span_panel_api-3.0.1/src/span_panel_api/reference_payloads/README.md +0 -23
- span_panel_api-3.0.1/src/span_panel_api/reference_payloads/__init__.py +0 -66
- span_panel_api-3.0.1/tests/test_schema_zero_adapter.py +0 -87
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/.gitignore +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/LICENSE +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.0.1/src/span_panel_api → span_panel_api-3.1.0/tests}/reference_payloads/homie_schema.json +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.0.1 → span_panel_api-3.1.0}/tests/test_shared_http_client.py +0 -0
|
@@ -7,6 +7,175 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
7
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
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
9
|
|
|
10
|
+
## [3.1.0]
|
|
11
|
+
|
|
12
|
+
A security release. Three things a caller could not previously find out — whether a control command was delivered, whether the panel's bootstrap traffic was encrypted, and whether the CA behind the MQTT broker is still the one that was there yesterday —
|
|
13
|
+
now have answers. **Install the matching adapter**: this release replaces four `SchemaAdapter` members and adds one, so `span-panel-api-schema-0` / `-1` must move to 1.1.0 at the same time. The extras (`span-panel-api[schema-0]`) carry the floor; a direct
|
|
14
|
+
install of the adapter distribution does not, and a 1.0.0 adapter against this bootstrap is rejected at discovery with a named error rather than misbehaving.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **A meter reading the panel has not sent is reported as absent instead of as zero.** Every energy and power field on `SpanCircuitSnapshot` and the six panel-level ones read off the lugs were filled with `0.0` whenever the property behind them carried no
|
|
19
|
+
value. A retained-topic replay hands a subscriber `$description` before the values it declares, so there is a window — on every connect, and again after every broker reconnect, because the adapter is rebuilt from a clean accumulator — in which every
|
|
20
|
+
circuit on the panel exists, is described, and has reported nothing. Throughout that window the snapshot stated that each of them was drawing no power and had accumulated no energy since it was installed.
|
|
21
|
+
|
|
22
|
+
**On a cumulative counter that is destructive rather than cosmetic.** A consumer cannot tell the fabricated zero from a meter that genuinely reads zero, and the reading a lifetime counter drops to when firmware resets it _is_ zero — so a consumer
|
|
23
|
+
compensating for counter resets books the entire counter as a compensation offset, and does it again on the next replay. `SpanPanel/span#259` is that failure on real hardware: an "energy dip" reported against essentially every circuit on each restart,
|
|
24
|
+
each dip equal to that circuit's whole lifetime counter, offsets reaching 8.18 MWh on a single circuit and roughly 10 MWh of fictional energy pushed into long-term statistics across one panel.
|
|
25
|
+
|
|
26
|
+
The fix is the discrimination rather than a new default: an unreported reading is `None`, a reported `0` is `0.0`, and the two no longer collapse into each other. It is per-property, so a circuit that has published half its meter reports the half it has.
|
|
27
|
+
**A synthesised `unmapped_tab_*` entry still reads zero** — an unoccupied breaker position genuinely draws nothing, and that is an assertion the adapter is entitled to make rather than a reading it failed to receive.
|
|
28
|
+
|
|
29
|
+
Two further consequences fall out of the same rule. **The panel-level fields were the worst case, not an edge case**: both lugs devices declare the same type and are told apart by the `info/direction` value they publish, so until that one property
|
|
30
|
+
arrives neither role resolves and all six fields — the whole site's import and export — were fabricated together. And **`dsm_state` no longer infers islanding from silence**: its fallback heuristic asks whether power is crossing the service entrance and
|
|
31
|
+
read "no power" out of "nothing has reported", declaring a site off-grid on the strength of a measurement nobody had made. With neither grid signal reported it now answers `UNKNOWN`, which the same function already returns when it cannot tell.
|
|
32
|
+
|
|
33
|
+
- **A relay or shed-priority command aimed at a circuit the panel declares non-commandable is refused instead of published.** `set_circuit_relay_target` and `set_circuit_priority_target` were pure string formatting from a circuit id and consulted no
|
|
34
|
+
declaration at all, so both setters published to a circuit commissioned always-on or never-backup — while the same adapters were already reading exactly that refusal into `SpanCircuitSnapshot.is_user_controllable` and `.is_never_backup`. Both now return
|
|
35
|
+
`ControlTarget | None`, matching the two controls that already refused, and `set_circuit_relay` / `set_circuit_priority` raise `SpanPanelServerError` the way `set_evse_charge_limit` does.
|
|
36
|
+
|
|
37
|
+
Nothing was user-visible, because the Home Assistant integration gates entity creation on `is_user_controllable`. That is not sufficient for two reasons. **Settability changes at runtime** — re-commissioning a circuit in place cycles that child device's
|
|
38
|
+
`$state` and republishes its `$description` with a new `$settable`, so an entity can outlive its own controllability and a setup-time gate cannot see it. And `set_circuit_relay` is **public API**: any caller can reach it without an entity, and the
|
|
39
|
+
library is where the refusal contract belongs.
|
|
40
|
+
|
|
41
|
+
**The refusal is the eBus specification's rule, not either adapter's.** `switch` 0.3 declares `relay` "Settable when `relay-controllable = true`" and defines `relay-controllable` false as "locked (for example a circuit commissioned as permanently on)",
|
|
42
|
+
so a consumer publishing to a locked circuit is writing to a property the specification says is not settable on that device. Under the parent/child schema both halves of the condition are on the wire and the relay refuses when **either** says no —
|
|
43
|
+
`$settable` absent from `switch/relay`, or `switch/relay-controllable` published `false`. The redundancy is deliberate: SPAN reports a firmware defect in which the `$settable` re-toggle on the runtime re-commissioning path is skipped until the service
|
|
44
|
+
restarts, so a consumer can meet a panel whose declaration is stale while the value is current, and the panel rejects an out-of-policy write regardless of what `$settable` last advertised. Across the two production enclosures captured — 27 circuits — the
|
|
45
|
+
two agree without exception. The flat schema predates capability nodes and declares settability per device _type_, so it cannot vary per circuit; it spells the same fact `always-on`, and the priority's `never-backup`. Each is the same reading the
|
|
46
|
+
snapshot already exposes.
|
|
47
|
+
|
|
48
|
+
A locked relay keeps a settable priority, which is the combination real panels publish and which `switch` 0.3 and `load-shed` 0.3 scope separately.
|
|
49
|
+
|
|
50
|
+
**Both adapters also refuse a circuit id the panel never published**, where the flat one used to build a topic for it. Its lookup read an unpublished value as the empty string, which parses as "not always-on" and read as permission, so any id at all was
|
|
51
|
+
writable on any panel — including the synthetic `unmapped_tab_*` keys the snapshot itself invents. The two adapters answer the same question and now answer it the same way, and `SchemaAdapter` states the guarantee rather than leaving it to each
|
|
52
|
+
implementation.
|
|
53
|
+
|
|
54
|
+
**And a device that declares no such property is refused as well**, which is not the same absence as a declared property carrying no `$settable`. Under the parent/child schema an absent `$settable` on `load-shed/priority` means settable — that is the
|
|
55
|
+
documented case where firmware declares the property and omits the attribute — but a BESS, a MID or the lugs declare no `load-shed` node at all, and reading their silence as permission resolved a write topic for a control those devices never offered.
|
|
56
|
+
|
|
57
|
+
- **A refused circuit command names the refusal it actually made.** An id the panel carries no circuit under was refused with "declares its relay non-commandable" and audited as `relay not commandable`, which asserts something about a circuit that does not
|
|
58
|
+
exist: it sends whoever reads it to a panel's commissioning to explain a mistyped id. The two cases now carry distinct messages and distinct `detail` values (`no such circuit`), and the distinction matters most in the audit trail, because `detail`
|
|
59
|
+
reaches `after_publish` and the Home Assistant integration writes it into a security log where it is read as a fact about the panel. `SchemaAdapter` gains `has_circuit` for it — consulted only once a target has already been refused, so it can relabel a
|
|
60
|
+
refusal but never cause one.
|
|
61
|
+
|
|
62
|
+
**`has_circuit` is a required protocol member**, and therefore the third adapter-contract change in this release alongside the four `set_*_topic` renames and the widened return types: `_derive_required_members` makes every public `SchemaAdapter` member
|
|
63
|
+
mandatory of every adapter wheel, so an adapter without it is rejected at discovery. That is not a new mismatch anyone can hit — a 1.0.0 adapter was already rejected by the renames — and the rejection names this member alongside them, with the same
|
|
64
|
+
remedy. `ADAPTER_CONTRACT_VERSION` still does not move: an added member is caught by name at discovery, which is what the constant's own docstring reserves it for.
|
|
65
|
+
|
|
66
|
+
- **A control the library refused before resolving an address is no longer invisible to `ControlInterceptor`.** `after_publish` is contracted to see every command, refusals included, but five refusals happened while resolving the target and therefore never
|
|
67
|
+
reached the publish path at all: a relay declared non-commandable, a priority declared locked, a charger with no settable limit, a panel with no islanding control, and an adopted property that is not settable. A consumer building a security audit on
|
|
68
|
+
`after_publish` — which is what the Home Assistant integration does — would have had a hole in it exactly where the interesting cases are, the highest-consequence control in the system among them.
|
|
69
|
+
|
|
70
|
+
Those now produce an `after_publish` record with `PublishState.FAILED` and a `detail` naming the refusal, before the `SpanPanelServerError` is raised. `before_publish` is deliberately **not** consulted for them: there is nothing to authorise, and a veto
|
|
71
|
+
would replace a specific reason with "vetoed".
|
|
72
|
+
|
|
73
|
+
- **A control command that was never sent no longer looks like one that succeeded.** All five setters returned `None` on three separate paths that published nothing: after `close()` (the adapter survives, the bridge does not, so the setter returned having
|
|
74
|
+
done nothing at all), with no paho client, and — the one that matters — while the broker was unreachable. A caller had no way to tell any of them from a breaker that actually opened.
|
|
75
|
+
- **A publish while the broker is known to be down is refused instead of queued.** paho keeps a QoS-1 publish in its outbound queue across a disconnect and sends it when the connection returns, reusing the same client, so a relay command issued during an
|
|
76
|
+
outage fired whenever the broker came back — minutes later, against a panel nobody was watching, with nothing in the UI having said so. The bridge now checks its connection **before** handing the message to paho, which is the only point at which refusing
|
|
77
|
+
is still possible. A message the transport declines is reported `FAILED`, and `FAILED` is a promise that nothing will be delivered later.
|
|
78
|
+
|
|
79
|
+
The refusal is bounded by what the transport can know: the check is only as fresh as paho's disconnect detection, which is a socket close or the keepalive. A broker that stops answering _without_ closing its socket leaves the connection looking healthy
|
|
80
|
+
for up to a keepalive interval and a half, and a publish in that window is still handed over and queued. That caller is told `UNCONFIRMED`, which promises nothing about delivery in either direction, so the outcome remains truthful — but "refused rather
|
|
81
|
+
than queued" describes a detected disconnect, not every disconnect.
|
|
82
|
+
|
|
83
|
+
- **A discarded command settles instead of waiting out its deadline.** Rebuilding the paho client — or tearing it down — empties the outbound queue; anything still awaiting acknowledgement used to wait out its full deadline (five seconds, for a relay) for
|
|
84
|
+
a PUBACK that could no longer arrive. Those now resolve immediately with an explicit "the transport discarded this message; delivery is unknown", so an audit trail carries a terminal state rather than a gap.
|
|
85
|
+
- **A failed authentication no longer puts the rejected passphrase in the exception message.** `register_v2` interpolated the response body into `SpanPanelAuthError`, and the panel's validation layer answers a bad passphrase with a 422 that echoes the
|
|
86
|
+
submitted credential back. Home Assistant shows that message in the UI, writes it to the config-flow log, and captures it in a diagnostics download. The exception now carries the status code only; the body goes to `DEBUG` with every credential-valued key
|
|
87
|
+
replaced, found by a recursive walk rather than a top-level scan — the 422 nests the echo two levels down, so a top-level scan would redact nothing in exactly the response most likely to hold a secret. A body that is not JSON is described by length and
|
|
88
|
+
content-type rather than shown.
|
|
89
|
+
- **A TLS failure during reconnect no longer re-anchors trust to whatever is answering.** The reconnect path refetched the panel's CA over unauthenticated HTTP and built its trust store from the result, so a panel presenting a certificate from a
|
|
90
|
+
_different_ CA was silently accepted. With `ca_pem` configured (below) neither connect nor rebuild fetches anything.
|
|
91
|
+
|
|
92
|
+
### Added
|
|
93
|
+
|
|
94
|
+
- **`PublishOutcome`, returned by every setter, saying how far a command got.** Four states, because the differences are ones a person acts on differently: `CONFIRMED` (the property reported the requested value on its own topic), `ACCEPTED` (the broker
|
|
95
|
+
acknowledged it, no transition seen), `UNCONFIRMED` (handed over, nothing came back before the deadline) and `FAILED` (never handed to the broker, and will not be delivered).
|
|
96
|
+
|
|
97
|
+
`UNCONFIRMED` **is not an error and does not raise.** It is the expected result of writing a value that is already current, and it is indistinguishable from a silent policy rejection until SPAN ships a reason code. A write whose value already matches
|
|
98
|
+
short-circuits to `UNCONFIRMED` with `no_op=True` immediately, compared in the panel's vocabulary rather than the caller's, so an automation that rewrites the same value on every run does not burn a deadline discovering that.
|
|
99
|
+
|
|
100
|
+
`CONFIRMED` is strong evidence and not proof: the panel coalesces every API client into a single `USER` requester, so an observed transition cannot be attributed to one specific write. Nothing is retried — a relay write is not idempotent in its physical
|
|
101
|
+
effect, and a racing external change may have legitimately reverted it.
|
|
102
|
+
|
|
103
|
+
- **`ca_pem` on `MqttClientConfig` — pin the panel CA instead of refetching it.** Supply it and the trust anchor is a configured value: no network call on connect, none on rebuild. Left unset, the previous behaviour stands (so this remains a minor release)
|
|
104
|
+
with one `WARNING` per bridge saying the anchor was obtained unauthenticated.
|
|
105
|
+
|
|
106
|
+
A pinned handshake that fails is **not** assumed to mean the CA rotated, because it usually does not: an expired leaf after a panel's clock reset, or a hostname mismatch after the panel moved, produce the identical `SSLCertVerificationError`, and `ssl`
|
|
107
|
+
exposes no peer chain on a verification failure. The library performs a separate, display-only fetch of the panel's advertised CA and compares fingerprints. Same fingerprint, or the fetch failed — keep retrying, because a panel reachable on 8883 and not
|
|
108
|
+
on its HTTP port is a panel mid-reboot and declaring a permanent failure on missing evidence would convert a transient into an outage. Different fingerprint — `SpanPanelCAChangedError`, on the initial connect as well as in the reconnect loop.
|
|
109
|
+
|
|
110
|
+
- **`build_panel_ssl_context(ca_pem)` and `ca_fingerprint(ca_pem)` are public.** A consumer that pins the CA needs the identical context and the identical fingerprint string on its own side of the pin; two implementations would drift, and the one that
|
|
111
|
+
drifted would be the security check.
|
|
112
|
+
|
|
113
|
+
- **`register_fatal_error_callback` — a typed channel for a transport that has stopped for good.** The reconnect loop is fire-and-forget, so an exception raised inside it killed the task invisibly and a consumer learned nothing. Distinct from the
|
|
114
|
+
connection callback on purpose: "disconnected" is what an ordinary outage looks like and waiting through it is correct, while this fires only for a failure no amount of waiting fixes. A consumer that registers nothing is still not left guessing —
|
|
115
|
+
`ping()` and `get_snapshot()` re-raise the terminal error.
|
|
116
|
+
|
|
117
|
+
- **`ControlInterceptor` — one veto-and-observe point covering all five setters.** `before_publish` may raise to refuse a command, and **the exception propagates unchanged**: the interceptor owns its type and its message, which is what lets a consumer
|
|
118
|
+
raise a framework-specific error with a translated message and have it reach the user intact. `after_publish` receives every command including the refusals (an audit that silently omits refusals is worse than no audit) and is fired as a task rather than
|
|
119
|
+
awaited, so a sink that merely hangs cannot stall every control call — the price being that ordering across commands is not guaranteed.
|
|
120
|
+
|
|
121
|
+
**This is a boundary against callers of this library and nothing more.** It does not constrain anything holding the broker credential: such a process publishes to the panel's broker directly and never reaches this code.
|
|
122
|
+
|
|
123
|
+
- **HTTPS for the bootstrap REST calls.** Every `auth.py` and `detection.py` function taking `host`/`port` now accepts `ssl_context`, as do `create_span_client` and `MqttClientConfig` (the MQTT client refetches the schema over HTTP at connect and on every
|
|
124
|
+
redispatch). Supplying one moves the call to `https://`; omitting it is byte-identical to 3.0.1. `download_ca_cert` is the one exception and stays on `http://` — it is the bootstrap, fetching the anchor everything else is checked against, so it has
|
|
125
|
+
nothing to check itself against. Its docstring now says so plainly, and it takes an `ssl_context` only for the caller that _already_ holds the anchor and wants a verified second copy.
|
|
126
|
+
|
|
127
|
+
### Changed
|
|
128
|
+
|
|
129
|
+
- **BREAKING FOR CONSUMERS: the energy and power fields on `SpanCircuitSnapshot` and `SpanPanelSnapshot` become `float | None`.** `instant_power_w`, `produced_energy_wh` and `consumed_energy_wh` on a circuit; `instant_grid_power_w`, `feedthrough_power_w`
|
|
130
|
+
and the four `*_energy_*_wh` on the panel. `None` means the panel has not reported that reading — see the entry under **Fixed** for why the previous `0.0` was not a safe stand-in. Anything doing arithmetic straight off one of these fields is the code
|
|
131
|
+
that has to change, and mypy names every site rather than leaving it to a runtime `TypeError`. Coalescing with `or 0` is rarely the right repair: it reintroduces exactly the fabrication this removes, one layer further out. A consumer rendering a value
|
|
132
|
+
should render "unknown"; a consumer accumulating one should skip the sample.
|
|
133
|
+
|
|
134
|
+
`ADAPTER_CONTRACT_VERSION` does not move. It guards the bootstrap-to-adapter calling convention — an `__init__` arity or a member whose meaning changed under its own name — and both adapters ship this change with the bootstrap in the same unpublished
|
|
135
|
+
release, so no adapter carrying the old behaviour is reachable. The already-published 1.0.0 adapters are refused at discovery on the existing floor.
|
|
136
|
+
|
|
137
|
+
- **`ControlCommand.topic` and `PublishOutcome.topic` become `str | None`.** A refusal made while resolving the address has no topic, and a command reported with one would name a string nothing was ever going to publish to. `None` appears only alongside
|
|
138
|
+
`PublishState.FAILED`. Additive for a consumer that only reads `state` and `detail`; an interceptor that passes `command.topic` somewhere expecting a `str` is the one that has to change, and does so under mypy rather than silently.
|
|
139
|
+
|
|
140
|
+
- **BREAKING FOR IMPLEMENTERS: `set_circuit_relay_target` and `set_circuit_priority_target` return `ControlTarget | None`.** `SchemaAdapter` declares the wider type, joining `set_dominant_power_source_target` and `set_evse_charge_limit_target`, which have
|
|
141
|
+
always refused this way. `ADAPTER_CONTRACT_VERSION` does not move, and the direction is why: an adapter still returning a bare `ControlTarget` satisfies the wider declaration — a narrower return is a valid implementation — and simply never exercises the
|
|
142
|
+
refusal, which is the pre-fix behaviour and no worse than it. The direction the contract version does not protect, a newer adapter against an older bootstrap, is unchanged by this.
|
|
143
|
+
|
|
144
|
+
- **BREAKING FOR IMPLEMENTERS: the five control-protocol setters return `PublishOutcome` instead of `None`.** `CircuitControlProtocol`, `PanelControlProtocol`, `EvseControlProtocol` and `AdoptedControlProtocol` all move. **This is additive for callers** —
|
|
145
|
+
a call site that ignores the return value compiles and behaves exactly as before — **and breaking for implementers**: any class type-checked against one of these protocols with `-> None` stops conforming. Test fakes, simulators, and any
|
|
146
|
+
`Callable[..., Awaitable[None]]` typed against a setter are precisely that, and they must be updated in the same upgrade.
|
|
147
|
+
|
|
148
|
+
- **BREAKING FOR IMPLEMENTERS: `SpanPanelClientProtocol` gains `register_fatal_error_callback`.** Same class of breakage as the setters and it needs the same treatment: additive for callers, but any fake, simulator or alternate transport implementing this
|
|
149
|
+
protocol stops conforming until it grows the method — under mypy, and at runtime too, since the protocol is `runtime_checkable` and a consumer that asks `isinstance` before offering a feature will silently stop offering it. It is declared on the protocol
|
|
150
|
+
rather than only on `SpanMqttClient` because the consumer codes against protocols, never against transport-specific classes, and it now depends on this channel.
|
|
151
|
+
|
|
152
|
+
- **BREAKING FOR ADAPTERS: `set_*_topic` becomes `set_*_target`, returning a `ControlTarget`.** Verifying a write means matching the topic it went to against the property that reports it, and only the adapter knows both — the two schemas spell the same
|
|
153
|
+
control differently (flat's relay is `(serial, circuit_id, "relay")`, parent/child's is `(circuit_id, "switch", "relay")`), and parsing them back out of a topic string would put wire-format knowledge in the transport, which is the one component whose job
|
|
154
|
+
is not to have any. `ControlTarget` carries the topic and that triple together, produced by one call so they cannot disagree.
|
|
155
|
+
|
|
156
|
+
The rename is deliberate rather than a return-type change under the old name. An adapter built for the old contract would keep the old name, pass discovery on presence, and then fail deep inside a setter with an `AttributeError` on a `str`; under a new
|
|
157
|
+
name it is rejected at discovery, where the remedy — upgrade the bootstrap and the adapter together — can still be named. `ADAPTER_CONTRACT_VERSION` stays **1**: the contract gained members and lost members, which discovery already detects by name,
|
|
158
|
+
rather than redefining one.
|
|
159
|
+
|
|
160
|
+
- **`port` is `int | None` on every bootstrap call, defaulting to `None`.** With a plain `int = 80` there is no way to distinguish an omitted port from one a caller deliberately set to 80, and the two need opposite answers once a scheme is in play: `None`
|
|
161
|
+
resolves to 80 without an `ssl_context` and 443 with one. An explicit `port=80` **together with** an `ssl_context` raises `SpanPanelValidationError` naming both values rather than guessing — it is exactly what a consumer that stored a port before it
|
|
162
|
+
pinned a CA produces, and both readings are defensible.
|
|
163
|
+
|
|
164
|
+
- **A supplied `ssl_context` now takes precedence over an injected `httpx.AsyncClient`.** httpx fixes `verify=` at construction, so a context cannot be applied to a client somebody else built; the previous behaviour of yielding an injected client untouched
|
|
165
|
+
would have meant a caller passing both got system trust while believing it had pinned the panel CA — a security control that appears to be on and is off. When both are supplied, a dedicated client is built for the call and closed after it. The cost is
|
|
166
|
+
named rather than hidden: those calls lose the injected client's connection pool, timeout and header policy. Acceptable because every caller here is bootstrap — registration, detection, schema, FQDN, status — a handful of calls per config entry.
|
|
167
|
+
|
|
168
|
+
### Removed
|
|
169
|
+
|
|
170
|
+
- **BREAKING: `span_panel_api.reference_payloads` is gone, and the wheel no longer carries `homie_schema.json`.** The captured `GET /api/v2/homie/schema` response is a fixture of this repository's test suite now, at
|
|
171
|
+
`tests/reference_payloads/homie_schema.json`, read through `homie_schema()` / `homie_schema_types()` there. Anyone importing the module from an installed distribution has to vendor the bytes instead — and should record which release they were taken from,
|
|
172
|
+
asserting that against `importlib.metadata.version("span-panel-api")`, so a pin that moves past a stale copy fails loudly rather than checking declarations against a schema no panel runs. That version claim is available to any consumer without a
|
|
173
|
+
checkout, which is what makes vendoring safe and is the whole reason this can be removed.
|
|
174
|
+
|
|
175
|
+
It shipped in the first place to spare consumers a copy that goes stale in silence, which was a real problem badly solved: no runtime path ever read the file, so every install of both distributions paid for test data it could not use, and the import
|
|
176
|
+
surface committed each distribution to a promise it never meant to make. Nothing declared the payloads as package data — a directory inside a package directory ships whether or not a manifest names it, which is exactly why this was easy to miss.
|
|
177
|
+
`tests/test_packaging.py` now fails if a capture reappears inside a shipped package, and CI asserts the same against every built wheel. See #162.
|
|
178
|
+
|
|
10
179
|
## [3.0.1]
|
|
11
180
|
|
|
12
181
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 3.0
|
|
3
|
+
Version: 3.1.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
|
|
@@ -20,9 +20,9 @@ Requires-Dist: httpx<1.0,>=0.28.1
|
|
|
20
20
|
Requires-Dist: paho-mqtt<3.0.0,>=2.0.0
|
|
21
21
|
Requires-Dist: pyyaml>=6.0.0
|
|
22
22
|
Provides-Extra: schema-0
|
|
23
|
-
Requires-Dist: span-panel-api-schema-0>=1.
|
|
23
|
+
Requires-Dist: span-panel-api-schema-0>=1.1.0; extra == 'schema-0'
|
|
24
24
|
Provides-Extra: schema-1
|
|
25
|
-
Requires-Dist: span-panel-api-schema-1>=1.
|
|
25
|
+
Requires-Dist: span-panel-api-schema-1>=1.1.0; extra == 'schema-1'
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
28
28
|
# SPAN Panel API
|
|
@@ -150,21 +150,25 @@ This ensures that the first `get_snapshot()` after connect returns human-readabl
|
|
|
150
150
|
|
|
151
151
|
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:
|
|
152
152
|
|
|
153
|
-
| Protocol
|
|
154
|
-
|
|
|
155
|
-
| `SpanPanelClientProtocol`
|
|
156
|
-
| `CircuitControlProtocol`
|
|
157
|
-
| `PanelControlProtocol`
|
|
158
|
-
| `EvseControlProtocol`
|
|
159
|
-
| `AdoptedControlProtocol`
|
|
160
|
-
| `
|
|
153
|
+
| Protocol | Purpose |
|
|
154
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------ |
|
|
155
|
+
| `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot`, `register_connection_callback` |
|
|
156
|
+
| `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` |
|
|
157
|
+
| `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` |
|
|
158
|
+
| `EvseControlProtocol` | Per-charger control: `set_evse_charge_limit(node_id, amps)` |
|
|
159
|
+
| `AdoptedControlProtocol` | Write to a settable property of a device this library models nothing for |
|
|
160
|
+
| `ControlInterceptionProtocol` | Install one veto-and-observe point for every control command: `set_control_interceptor` |
|
|
161
|
+
| `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` |
|
|
161
162
|
|
|
162
163
|
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
|
|
163
164
|
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
|
|
164
165
|
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
|
|
165
166
|
through it.
|
|
166
167
|
|
|
167
|
-
|
|
168
|
+
`ControlInterceptionProtocol` is separate from the four control protocols rather than a member of them because adding it there would break every implementer of those protocols a second time in one release, and separate from `StreamingCapableProtocol`
|
|
169
|
+
because a transport could reasonably offer one and not the other.
|
|
170
|
+
|
|
171
|
+
One further 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
|
|
168
172
|
transport-specific classes.
|
|
169
173
|
|
|
170
174
|
### Snapshots
|
|
@@ -352,13 +356,77 @@ client.set_snapshot_interval(0)
|
|
|
352
356
|
|
|
353
357
|
```python
|
|
354
358
|
# Set circuit relay (OPEN/CLOSED)
|
|
355
|
-
await client.set_circuit_relay("circuit-uuid", "OPEN")
|
|
359
|
+
outcome = await client.set_circuit_relay("circuit-uuid", "OPEN")
|
|
356
360
|
await client.set_circuit_relay("circuit-uuid", "CLOSED")
|
|
357
361
|
|
|
358
362
|
# Set circuit shed priority (NEVER / SOC_THRESHOLD / OFF_GRID)
|
|
359
363
|
await client.set_circuit_priority("circuit-uuid", "NEVER")
|
|
360
364
|
```
|
|
361
365
|
|
|
366
|
+
Every setter returns a `PublishOutcome` saying how far the command got. Ignoring it is fine and behaves as it always did; reading it is how a caller tells a breaker that opened from a command that was never sent.
|
|
367
|
+
|
|
368
|
+
| `outcome.state` | What it means |
|
|
369
|
+
| -------------------------- | ------------------------------------------------------------------------------- |
|
|
370
|
+
| `PublishState.CONFIRMED` | The property reported the requested value on its own topic |
|
|
371
|
+
| `PublishState.ACCEPTED` | The broker acknowledged the message; no transition observed before the deadline |
|
|
372
|
+
| `PublishState.UNCONFIRMED` | Handed over, and nothing came back before the deadline |
|
|
373
|
+
| `PublishState.FAILED` | Never handed to the broker, and will not be delivered |
|
|
374
|
+
|
|
375
|
+
`UNCONFIRMED` **is not an error and does not raise.** It is the expected result of writing a value that is already current — that case short-circuits immediately with `outcome.no_op` set rather than burning the deadline — and it is indistinguishable from a
|
|
376
|
+
silent policy rejection by the panel until SPAN ships a reason code. `FAILED` is the one state that is a promise about the future, which is why the transport refuses to publish while the broker is unreachable instead of letting paho queue the message and
|
|
377
|
+
deliver it minutes later. `CONFIRMED` is strong evidence rather than proof: the panel coalesces every API client into a single `USER` requester, so an observed transition cannot be attributed to one specific write. Nothing is retried — a relay write is not
|
|
378
|
+
idempotent in its physical effect.
|
|
379
|
+
|
|
380
|
+
**A control the panel declares non-commandable raises rather than returning an outcome**, because there is no topic to publish to and so nothing to report on. `SpanPanelServerError` is raised for a relay on a circuit commissioned always-on, for the shed
|
|
381
|
+
priority of a circuit commissioned never-backup, and for a charger with no settable charge-current limit. The same facts are on the snapshot ahead of the call — `SpanCircuitSnapshot.is_user_controllable` and `.is_never_backup` — so a consumer that offers
|
|
382
|
+
the control only where the panel offers it will not meet this; it is the backstop for the case a setup-time gate cannot see, since re-commissioning a circuit in place changes its settability while an entity built from the earlier snapshot is still alive.
|
|
383
|
+
|
|
384
|
+
### Control Interception
|
|
385
|
+
|
|
386
|
+
A consumer with a notion of who is asking can refuse a command before it is published, and record every command in one place rather than in five setters that will drift:
|
|
387
|
+
|
|
388
|
+
```python
|
|
389
|
+
class Gate:
|
|
390
|
+
async def before_publish(self, command: ControlCommand) -> None:
|
|
391
|
+
if not authorised(command):
|
|
392
|
+
raise PermissionError(f"not allowed to write {command.property_id}")
|
|
393
|
+
|
|
394
|
+
async def after_publish(self, command: ControlCommand, outcome: PublishOutcome) -> None:
|
|
395
|
+
audit.record(command, outcome.state)
|
|
396
|
+
|
|
397
|
+
client.set_control_interceptor(Gate())
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
One interceptor at a time, replaceable; pass `None` to remove it. A veto's exception propagates to the caller **unchanged**, so a consumer raising a framework-specific error with a translated message gets it through intact. `after_publish` fires for
|
|
401
|
+
refusals too — with `FAILED` and a `vetoed` detail — because an audit that silently omits refusals is worse than no audit; it runs as a task rather than being awaited, so a sink that hangs cannot stall every control call, and ordering across commands is
|
|
402
|
+
therefore not guaranteed.
|
|
403
|
+
|
|
404
|
+
That includes the refusals this library makes on the panel's behalf, which never reach a topic at all: a relay the panel declares non-commandable arrives at `after_publish` with `FAILED`, a `detail` naming the refusal, and `command.topic` set to `None`.
|
|
405
|
+
`before_publish` is not consulted for those — there is nothing to authorise, and a veto would replace a specific reason with "vetoed" — so an interceptor must treat `topic` as optional and read `state` and `detail` as the machine-readable half.
|
|
406
|
+
|
|
407
|
+
**This is a boundary against callers of this library and nothing more.** Anything holding the broker credential publishes to the panel directly and never reaches this code.
|
|
408
|
+
|
|
409
|
+
### Pinning the Panel CA
|
|
410
|
+
|
|
411
|
+
By default the MQTT bridge fetches the panel's CA over unauthenticated HTTP on every connect and trusts whatever answers, which also means a reconnect can silently re-anchor trust to a different CA. Supply the PEM instead and the anchor becomes a
|
|
412
|
+
configured value — no network call on connect or on rebuild:
|
|
413
|
+
|
|
414
|
+
```python
|
|
415
|
+
config = MqttClientConfig(..., ca_pem=stored_pem)
|
|
416
|
+
|
|
417
|
+
# The same context and the same fingerprint string the library uses, so a
|
|
418
|
+
# consumer's own HTTPS calls and its own pin cannot drift from the library's.
|
|
419
|
+
context = build_panel_ssl_context(stored_pem)
|
|
420
|
+
fingerprint = ca_fingerprint(stored_pem)
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Leaving `ca_pem` unset keeps the previous behaviour, with one `WARNING` per bridge recording that the anchor was obtained unauthenticated. With it set, a certificate-verification failure is diagnosed rather than assumed: an expired leaf after a panel's
|
|
424
|
+
clock reset and a hostname mismatch after the panel moved both produce the identical error, so the library refetches the advertised CA for comparison only and keeps retrying unless the fingerprint has actually changed — at which point it raises
|
|
425
|
+
`SpanPanelCAChangedError` carrying both fingerprints and stops. Register `register_fatal_error_callback` to be told; a consumer that registers nothing still cannot mistake a dead bridge for a healthy one, because `ping()` and `get_snapshot()` re-raise.
|
|
426
|
+
|
|
427
|
+
The bootstrap REST calls take an `ssl_context` for the same purpose. `download_ca_cert` is the one exception and stays on plain HTTP — it fetches the anchor everything else is checked against, so it has nothing to check itself against, and its result must
|
|
428
|
+
be fingerprint-confirmed out of band before it is trusted.
|
|
429
|
+
|
|
362
430
|
### Pending-State Detection
|
|
363
431
|
|
|
364
432
|
When the panel publishes Homie `$target` properties, `SpanCircuitSnapshot` exposes the desired state alongside the actual state:
|
|
@@ -486,26 +554,11 @@ The `PanelCapability` flag enum advertises transport features at runtime:
|
|
|
486
554
|
|
|
487
555
|
## Reference Payloads
|
|
488
556
|
|
|
489
|
-
Captures of what a panel actually serves
|
|
490
|
-
|
|
491
|
-
```python
|
|
492
|
-
from span_panel_api.reference_payloads import homie_schema, homie_schema_types
|
|
493
|
-
|
|
494
|
-
document = homie_schema() # the captured GET /api/v2/homie/schema response
|
|
495
|
-
types = homie_schema_types() # its `types` map, typed as HomieSchemaTypes
|
|
496
|
-
```
|
|
497
|
-
|
|
498
|
-
`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.
|
|
499
|
-
|
|
500
|
-
The parent/child device tree is the schema_1 counterpart and ships from that adapter, with the parser that can interpret it:
|
|
501
|
-
|
|
502
|
-
```python
|
|
503
|
-
from span_panel_api_schema_1.reference_payloads import devices_from_tree, parent_child_tree
|
|
504
|
-
|
|
505
|
-
devices = devices_from_tree(parent_child_tree())
|
|
506
|
-
```
|
|
557
|
+
Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree — live in [`tests/reference_payloads/`](tests/reference_payloads/README.md), with their provenance.
|
|
507
558
|
|
|
508
|
-
|
|
559
|
+
**They are fixtures of this repository, not package data.** Until 3.1.0 they sat inside the two source packages and were therefore carried in the wheels, which no runtime path ever read. `span_panel_api.reference_payloads` and
|
|
560
|
+
`span_panel_api_schema_1.reference_payloads` no longer exist; a consumer that was importing them should vendor the bytes it needs and record the release it took them from, asserting that against `importlib.metadata.version(...)` so a moved pin that outruns
|
|
561
|
+
the copy fails loudly instead of testing against a schema no panel runs.
|
|
509
562
|
|
|
510
563
|
## Project Structure
|
|
511
564
|
|
|
@@ -526,7 +579,6 @@ src/span_panel_api/ # distribution: span-panel-api (no parser)
|
|
|
526
579
|
├── phase_validation.py # Electrical phase utilities
|
|
527
580
|
├── protocol.py # PEP 544 protocols, SchemaAdapter, PanelCapability flags
|
|
528
581
|
├── schema_drift.py # Reporting a panel that outruns what we can read
|
|
529
|
-
├── reference_payloads/ # Captured GET /api/v2/homie/schema, shipped as package data
|
|
530
582
|
└── mqtt/
|
|
531
583
|
├── __init__.py
|
|
532
584
|
├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern)
|
|
@@ -544,7 +596,7 @@ packages/schema-1/ # distribution: span-panel-api-schema-1
|
|
|
544
596
|
├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed
|
|
545
597
|
└── src/span_panel_api_schema_1/
|
|
546
598
|
# Parent/child parser: ControllerRoutes, snapshot mapper,
|
|
547
|
-
# adoption, catalog validator, spec_lock.json
|
|
599
|
+
# adoption, catalog validator, spec_lock.json
|
|
548
600
|
```
|
|
549
601
|
|
|
550
602
|
## Development
|
|
@@ -123,21 +123,25 @@ This ensures that the first `get_snapshot()` after connect returns human-readabl
|
|
|
123
123
|
|
|
124
124
|
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:
|
|
125
125
|
|
|
126
|
-
| Protocol
|
|
127
|
-
|
|
|
128
|
-
| `SpanPanelClientProtocol`
|
|
129
|
-
| `CircuitControlProtocol`
|
|
130
|
-
| `PanelControlProtocol`
|
|
131
|
-
| `EvseControlProtocol`
|
|
132
|
-
| `AdoptedControlProtocol`
|
|
133
|
-
| `
|
|
126
|
+
| Protocol | Purpose |
|
|
127
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------ |
|
|
128
|
+
| `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot`, `register_connection_callback` |
|
|
129
|
+
| `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` |
|
|
130
|
+
| `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` |
|
|
131
|
+
| `EvseControlProtocol` | Per-charger control: `set_evse_charge_limit(node_id, amps)` |
|
|
132
|
+
| `AdoptedControlProtocol` | Write to a settable property of a device this library models nothing for |
|
|
133
|
+
| `ControlInterceptionProtocol` | Install one veto-and-observe point for every control command: `set_control_interceptor` |
|
|
134
|
+
| `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` |
|
|
134
135
|
|
|
135
136
|
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
|
|
136
137
|
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
|
|
137
138
|
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
|
|
138
139
|
through it.
|
|
139
140
|
|
|
140
|
-
|
|
141
|
+
`ControlInterceptionProtocol` is separate from the four control protocols rather than a member of them because adding it there would break every implementer of those protocols a second time in one release, and separate from `StreamingCapableProtocol`
|
|
142
|
+
because a transport could reasonably offer one and not the other.
|
|
143
|
+
|
|
144
|
+
One further 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
|
|
141
145
|
transport-specific classes.
|
|
142
146
|
|
|
143
147
|
### Snapshots
|
|
@@ -325,13 +329,77 @@ client.set_snapshot_interval(0)
|
|
|
325
329
|
|
|
326
330
|
```python
|
|
327
331
|
# Set circuit relay (OPEN/CLOSED)
|
|
328
|
-
await client.set_circuit_relay("circuit-uuid", "OPEN")
|
|
332
|
+
outcome = await client.set_circuit_relay("circuit-uuid", "OPEN")
|
|
329
333
|
await client.set_circuit_relay("circuit-uuid", "CLOSED")
|
|
330
334
|
|
|
331
335
|
# Set circuit shed priority (NEVER / SOC_THRESHOLD / OFF_GRID)
|
|
332
336
|
await client.set_circuit_priority("circuit-uuid", "NEVER")
|
|
333
337
|
```
|
|
334
338
|
|
|
339
|
+
Every setter returns a `PublishOutcome` saying how far the command got. Ignoring it is fine and behaves as it always did; reading it is how a caller tells a breaker that opened from a command that was never sent.
|
|
340
|
+
|
|
341
|
+
| `outcome.state` | What it means |
|
|
342
|
+
| -------------------------- | ------------------------------------------------------------------------------- |
|
|
343
|
+
| `PublishState.CONFIRMED` | The property reported the requested value on its own topic |
|
|
344
|
+
| `PublishState.ACCEPTED` | The broker acknowledged the message; no transition observed before the deadline |
|
|
345
|
+
| `PublishState.UNCONFIRMED` | Handed over, and nothing came back before the deadline |
|
|
346
|
+
| `PublishState.FAILED` | Never handed to the broker, and will not be delivered |
|
|
347
|
+
|
|
348
|
+
`UNCONFIRMED` **is not an error and does not raise.** It is the expected result of writing a value that is already current — that case short-circuits immediately with `outcome.no_op` set rather than burning the deadline — and it is indistinguishable from a
|
|
349
|
+
silent policy rejection by the panel until SPAN ships a reason code. `FAILED` is the one state that is a promise about the future, which is why the transport refuses to publish while the broker is unreachable instead of letting paho queue the message and
|
|
350
|
+
deliver it minutes later. `CONFIRMED` is strong evidence rather than proof: the panel coalesces every API client into a single `USER` requester, so an observed transition cannot be attributed to one specific write. Nothing is retried — a relay write is not
|
|
351
|
+
idempotent in its physical effect.
|
|
352
|
+
|
|
353
|
+
**A control the panel declares non-commandable raises rather than returning an outcome**, because there is no topic to publish to and so nothing to report on. `SpanPanelServerError` is raised for a relay on a circuit commissioned always-on, for the shed
|
|
354
|
+
priority of a circuit commissioned never-backup, and for a charger with no settable charge-current limit. The same facts are on the snapshot ahead of the call — `SpanCircuitSnapshot.is_user_controllable` and `.is_never_backup` — so a consumer that offers
|
|
355
|
+
the control only where the panel offers it will not meet this; it is the backstop for the case a setup-time gate cannot see, since re-commissioning a circuit in place changes its settability while an entity built from the earlier snapshot is still alive.
|
|
356
|
+
|
|
357
|
+
### Control Interception
|
|
358
|
+
|
|
359
|
+
A consumer with a notion of who is asking can refuse a command before it is published, and record every command in one place rather than in five setters that will drift:
|
|
360
|
+
|
|
361
|
+
```python
|
|
362
|
+
class Gate:
|
|
363
|
+
async def before_publish(self, command: ControlCommand) -> None:
|
|
364
|
+
if not authorised(command):
|
|
365
|
+
raise PermissionError(f"not allowed to write {command.property_id}")
|
|
366
|
+
|
|
367
|
+
async def after_publish(self, command: ControlCommand, outcome: PublishOutcome) -> None:
|
|
368
|
+
audit.record(command, outcome.state)
|
|
369
|
+
|
|
370
|
+
client.set_control_interceptor(Gate())
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
One interceptor at a time, replaceable; pass `None` to remove it. A veto's exception propagates to the caller **unchanged**, so a consumer raising a framework-specific error with a translated message gets it through intact. `after_publish` fires for
|
|
374
|
+
refusals too — with `FAILED` and a `vetoed` detail — because an audit that silently omits refusals is worse than no audit; it runs as a task rather than being awaited, so a sink that hangs cannot stall every control call, and ordering across commands is
|
|
375
|
+
therefore not guaranteed.
|
|
376
|
+
|
|
377
|
+
That includes the refusals this library makes on the panel's behalf, which never reach a topic at all: a relay the panel declares non-commandable arrives at `after_publish` with `FAILED`, a `detail` naming the refusal, and `command.topic` set to `None`.
|
|
378
|
+
`before_publish` is not consulted for those — there is nothing to authorise, and a veto would replace a specific reason with "vetoed" — so an interceptor must treat `topic` as optional and read `state` and `detail` as the machine-readable half.
|
|
379
|
+
|
|
380
|
+
**This is a boundary against callers of this library and nothing more.** Anything holding the broker credential publishes to the panel directly and never reaches this code.
|
|
381
|
+
|
|
382
|
+
### Pinning the Panel CA
|
|
383
|
+
|
|
384
|
+
By default the MQTT bridge fetches the panel's CA over unauthenticated HTTP on every connect and trusts whatever answers, which also means a reconnect can silently re-anchor trust to a different CA. Supply the PEM instead and the anchor becomes a
|
|
385
|
+
configured value — no network call on connect or on rebuild:
|
|
386
|
+
|
|
387
|
+
```python
|
|
388
|
+
config = MqttClientConfig(..., ca_pem=stored_pem)
|
|
389
|
+
|
|
390
|
+
# The same context and the same fingerprint string the library uses, so a
|
|
391
|
+
# consumer's own HTTPS calls and its own pin cannot drift from the library's.
|
|
392
|
+
context = build_panel_ssl_context(stored_pem)
|
|
393
|
+
fingerprint = ca_fingerprint(stored_pem)
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Leaving `ca_pem` unset keeps the previous behaviour, with one `WARNING` per bridge recording that the anchor was obtained unauthenticated. With it set, a certificate-verification failure is diagnosed rather than assumed: an expired leaf after a panel's
|
|
397
|
+
clock reset and a hostname mismatch after the panel moved both produce the identical error, so the library refetches the advertised CA for comparison only and keeps retrying unless the fingerprint has actually changed — at which point it raises
|
|
398
|
+
`SpanPanelCAChangedError` carrying both fingerprints and stops. Register `register_fatal_error_callback` to be told; a consumer that registers nothing still cannot mistake a dead bridge for a healthy one, because `ping()` and `get_snapshot()` re-raise.
|
|
399
|
+
|
|
400
|
+
The bootstrap REST calls take an `ssl_context` for the same purpose. `download_ca_cert` is the one exception and stays on plain HTTP — it fetches the anchor everything else is checked against, so it has nothing to check itself against, and its result must
|
|
401
|
+
be fingerprint-confirmed out of band before it is trusted.
|
|
402
|
+
|
|
335
403
|
### Pending-State Detection
|
|
336
404
|
|
|
337
405
|
When the panel publishes Homie `$target` properties, `SpanCircuitSnapshot` exposes the desired state alongside the actual state:
|
|
@@ -459,26 +527,11 @@ The `PanelCapability` flag enum advertises transport features at runtime:
|
|
|
459
527
|
|
|
460
528
|
## Reference Payloads
|
|
461
529
|
|
|
462
|
-
Captures of what a panel actually serves
|
|
463
|
-
|
|
464
|
-
```python
|
|
465
|
-
from span_panel_api.reference_payloads import homie_schema, homie_schema_types
|
|
466
|
-
|
|
467
|
-
document = homie_schema() # the captured GET /api/v2/homie/schema response
|
|
468
|
-
types = homie_schema_types() # its `types` map, typed as HomieSchemaTypes
|
|
469
|
-
```
|
|
470
|
-
|
|
471
|
-
`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.
|
|
472
|
-
|
|
473
|
-
The parent/child device tree is the schema_1 counterpart and ships from that adapter, with the parser that can interpret it:
|
|
474
|
-
|
|
475
|
-
```python
|
|
476
|
-
from span_panel_api_schema_1.reference_payloads import devices_from_tree, parent_child_tree
|
|
477
|
-
|
|
478
|
-
devices = devices_from_tree(parent_child_tree())
|
|
479
|
-
```
|
|
530
|
+
Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree — live in [`tests/reference_payloads/`](tests/reference_payloads/README.md), with their provenance.
|
|
480
531
|
|
|
481
|
-
|
|
532
|
+
**They are fixtures of this repository, not package data.** Until 3.1.0 they sat inside the two source packages and were therefore carried in the wheels, which no runtime path ever read. `span_panel_api.reference_payloads` and
|
|
533
|
+
`span_panel_api_schema_1.reference_payloads` no longer exist; a consumer that was importing them should vendor the bytes it needs and record the release it took them from, asserting that against `importlib.metadata.version(...)` so a moved pin that outruns
|
|
534
|
+
the copy fails loudly instead of testing against a schema no panel runs.
|
|
482
535
|
|
|
483
536
|
## Project Structure
|
|
484
537
|
|
|
@@ -499,7 +552,6 @@ src/span_panel_api/ # distribution: span-panel-api (no parser)
|
|
|
499
552
|
├── phase_validation.py # Electrical phase utilities
|
|
500
553
|
├── protocol.py # PEP 544 protocols, SchemaAdapter, PanelCapability flags
|
|
501
554
|
├── schema_drift.py # Reporting a panel that outruns what we can read
|
|
502
|
-
├── reference_payloads/ # Captured GET /api/v2/homie/schema, shipped as package data
|
|
503
555
|
└── mqtt/
|
|
504
556
|
├── __init__.py
|
|
505
557
|
├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern)
|
|
@@ -517,7 +569,7 @@ packages/schema-1/ # distribution: span-panel-api-schema-1
|
|
|
517
569
|
├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed
|
|
518
570
|
└── src/span_panel_api_schema_1/
|
|
519
571
|
# Parent/child parser: ControllerRoutes, snapshot mapper,
|
|
520
|
-
# adoption, catalog validator, spec_lock.json
|
|
572
|
+
# adoption, catalog validator, spec_lock.json
|
|
521
573
|
```
|
|
522
574
|
|
|
523
575
|
## Development
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "span-panel-api"
|
|
3
|
-
version = "3.0
|
|
3
|
+
version = "3.1.0"
|
|
4
4
|
description = "A client library for SPAN Panel API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "SpanPanel"}
|
|
@@ -53,8 +53,13 @@ dependencies = [
|
|
|
53
53
|
# is pip's own signal that prereleases are acceptable for that requirement, so a
|
|
54
54
|
# `>=1.0.0b5` floor here would leave a released install willing to resolve a
|
|
55
55
|
# future beta of the adapter without anyone asking for one.
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
# Raised to 1.1.0 for 3.1.0, and this floor is not cosmetic. The `SchemaAdapter`
|
|
57
|
+
# protocol replaced `set_*_topic` with `set_*_target`, and `_derive_required_members`
|
|
58
|
+
# makes every public protocol member mandatory of every adapter wheel — so a 1.0.0
|
|
59
|
+
# adapter installed against this bootstrap is rejected at discovery rather than
|
|
60
|
+
# working in a degraded way. The two must move together.
|
|
61
|
+
schema-0 = ["span-panel-api-schema-0>=1.1.0"]
|
|
62
|
+
schema-1 = ["span-panel-api-schema-1>=1.1.0"]
|
|
58
63
|
|
|
59
64
|
[project.urls]
|
|
60
65
|
Homepage = "https://github.com/SpanPanel/span-panel-api"
|