span-panel-api 3.1.1__tar.gz → 3.3.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.1.1 → span_panel_api-3.3.0}/.gitignore +0 -5
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/CHANGELOG.md +25 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/PKG-INFO +43 -11
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/README.md +42 -10
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/pyproject.toml +36 -4
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/__init__.py +10 -1
- span_panel_api-3.3.0/src/span_panel_api/_ssl.py +364 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/exceptions.py +11 -3
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/client.py +47 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/connection.py +153 -34
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/const.py +8 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/protocol.py +25 -5
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/conftest.py +7 -7
- span_panel_api-3.3.0/tests/fixtures/panelbench_wire.json +684 -0
- span_panel_api-3.3.0/tests/fixtures/v2/README.md +14 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/reference_payloads/README.md +27 -19
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/reference_payloads/bootstrap.py +9 -4
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/reference_payloads/schema_one.py +10 -5
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_adoption.py +25 -1
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_ca_pinning.py +35 -6
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_catalog_divergence.py +16 -38
- span_panel_api-3.3.0/tests/test_leaf_name_mismatch.py +465 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_packaging.py +34 -32
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_public_api_unchanged.py +13 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_reference_tree_values.py +7 -7
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_migration_delta.py +11 -3
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_adapter.py +1 -1
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_circuits.py +43 -5
- span_panel_api-3.3.0/tests/test_schema_one_conformance.py +629 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_connection_health.py +2 -2
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_control_refusal.py +137 -41
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_discovery.py +1 -1
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_panel.py +11 -11
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_snapshot.py +4 -4
- span_panel_api-3.3.0/tests/test_ssl_context.py +326 -0
- span_panel_api-3.3.0/tests/tls_fixtures.py +246 -0
- span_panel_api-3.1.1/src/span_panel_api/_ssl.py +0 -104
- span_panel_api-3.1.1/tests/fixtures/v2/README.md +0 -14
- span_panel_api-3.1.1/tests/reference_payloads/homie_schema.json +0 -420
- span_panel_api-3.1.1/tests/reference_payloads/parent_child_tree.json +0 -234
- span_panel_api-3.1.1/tests/test_schema_one_against_simulator.py +0 -260
- span_panel_api-3.1.1/tests/test_schema_one_conformance.py +0 -828
- span_panel_api-3.1.1/tests/test_ssl_context.py +0 -234
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/LICENSE +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/auth.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/models.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_https_transport.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_mqtt_homie.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_plaintext_warning.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_rest_transport_contract.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_v2_status_parser.py +0 -0
|
@@ -40,8 +40,3 @@ coverage_output.log
|
|
|
40
40
|
# of which belongs in a repository. The differential that reads them commits its
|
|
41
41
|
# *verdict* only, never the capture, and skips when the file is absent.
|
|
42
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/
|
|
@@ -7,6 +7,31 @@ 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.3.0]
|
|
11
|
+
|
|
12
|
+
A pinned panel that has moved is no longer reported the same way as a panel whose clock reset, so a consumer can put the remedy in front of a user instead of retrying in silence.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`LeafNameMismatch` reports a broker whose certificate the pinned CA validates and which names somewhere other than the configured address**, carrying that address and the names the certificate does carry.
|
|
17
|
+
- **`register_leaf_mismatch_callback` delivers that report**, at most once per outage and re-armed by the next successful connect, returning an unregister function like the other callback channels.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **The warning logged when a pinned handshake fails against an unchanged CA now names which failure it is** — an expired or otherwise rejected certificate, an unreachable broker, or a certificate that names somewhere else — instead of saying it could be
|
|
22
|
+
either.
|
|
23
|
+
- **A moved panel is still retried and never terminal**, because the address can come back on its own and the report exists to make the alternative remedy visible rather than to stop the transport.
|
|
24
|
+
|
|
25
|
+
## [3.2.0]
|
|
26
|
+
|
|
27
|
+
A consumer pinned to a panel's CA cannot currently tell a panel that has moved from something impersonating one, because the two produce the same verification failure. This release splits the question.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **`build_panel_ssl_context` takes `check_hostname`**, so a caller can verify that a peer holds a key the pinned CA signed without also asserting that the certificate names the address it was dialled by.
|
|
32
|
+
- **`leaf_names_host` decides the name binding on its own**, hand-written against `getpeercert()` because `ssl.match_hostname` was removed in Python 3.12, and stricter than that function was: no wildcards, no `commonName` fallback, and DNS and IP entries
|
|
33
|
+
that never stand in for one another.
|
|
34
|
+
|
|
10
35
|
## [3.1.1]
|
|
11
36
|
|
|
12
37
|
A follow-up to 3.1.0's security work, with no API change and no adapter move required.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.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
|
|
@@ -420,9 +420,25 @@ context = build_panel_ssl_context(stored_pem)
|
|
|
420
420
|
fingerprint = ca_fingerprint(stored_pem)
|
|
421
421
|
```
|
|
422
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
|
|
424
|
-
|
|
425
|
-
|
|
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, in two steps — a rotated CA, an
|
|
424
|
+
expired leaf and a panel that has moved all raise the identical error, and the failed handshake carries no evidence about which.
|
|
425
|
+
|
|
426
|
+
First the library refetches the advertised CA, for comparison only. If the fingerprint has changed it raises `SpanPanelCAChangedError` carrying both fingerprints and stops. Register `register_fatal_error_callback` to be told; a consumer that registers
|
|
427
|
+
nothing still cannot mistake a dead bridge for a healthy one, because `ping()` and `get_snapshot()` re-raise.
|
|
428
|
+
|
|
429
|
+
If the fingerprint matches, the panel is still the panel and the library asks one further question: a second handshake to the broker with hostname checking relaxed — the chain, the signature and the expiry still verified against the pin — to see whether
|
|
430
|
+
the certificate names the address being dialled.
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
def moved(mismatch: LeafNameMismatch) -> None:
|
|
434
|
+
print(f"configured as {mismatch.host}, certificate names {', '.join(mismatch.leaf_names)}")
|
|
435
|
+
|
|
436
|
+
unregister = client.register_leaf_mismatch_callback(moved)
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
**This is not fatal and the transport keeps retrying**, because a returning DHCP lease fixes it without anyone's help; what the callback is for is putting the other remedy — re-point the configuration at one of the names reported — in front of a user who
|
|
440
|
+
would otherwise see only an outage. It fires at most once per outage and is re-armed by the next successful connect. An expired leaf reports nothing, because nothing anyone does helps and the panel recovers on its own once it has the time again. Neither
|
|
441
|
+
handshake can re-anchor anything: both are diagnostic, and the pin is the pin whatever the panel served.
|
|
426
442
|
|
|
427
443
|
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
444
|
be fingerprint-confirmed out of band before it is trusted.
|
|
@@ -554,11 +570,25 @@ The `PanelCapability` flag enum advertises transport features at runtime:
|
|
|
554
570
|
|
|
555
571
|
## Reference Payloads
|
|
556
572
|
|
|
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 —
|
|
573
|
+
Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree — ship as package data of the adapter that parses each, and their provenance is documented in
|
|
574
|
+
[`tests/reference_payloads/`](tests/reference_payloads/README.md) beside the loaders that read them.
|
|
575
|
+
|
|
576
|
+
| Capture | Read from |
|
|
577
|
+
| ------------------------ | ---------------------------------------------------------- |
|
|
578
|
+
| `homie_schema.json` | `span_panel_api_schema_0/reference/homie_schema.json` |
|
|
579
|
+
| `parent_child_tree.json` | `span_panel_api_schema_1/reference/parent_child_tree.json` |
|
|
580
|
+
|
|
581
|
+
**Test-support data, and no runtime path reads either.** They ship so a downstream test suite pinned to a version of an adapter reads the same bytes that version was tested against, out of its own site-packages:
|
|
582
|
+
|
|
583
|
+
```python
|
|
584
|
+
from importlib.resources import files
|
|
585
|
+
import json
|
|
586
|
+
|
|
587
|
+
schema = json.loads((files("span_panel_api_schema_0") / "reference" / "homie_schema.json").read_text(encoding="utf-8"))
|
|
588
|
+
```
|
|
558
589
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
the copy fails loudly instead of testing against a schema no panel runs.
|
|
590
|
+
The bootstrap distribution ships neither: it registers no adapter and parses nothing. `span_panel_api.reference_payloads` and `span_panel_api_schema_1.reference_payloads` — the importable modules that existed until 3.1.0 — are still gone; these are data
|
|
591
|
+
files read through `importlib.resources`, not an import surface.
|
|
562
592
|
|
|
563
593
|
## Project Structure
|
|
564
594
|
|
|
@@ -589,13 +619,15 @@ src/span_panel_api/ # distribution: span-panel-api (no parser)
|
|
|
589
619
|
|
|
590
620
|
packages/schema-0/ # distribution: span-panel-api-schema-0
|
|
591
621
|
└── src/span_panel_api_schema_0/
|
|
592
|
-
|
|
622
|
+
├── reference/ # homie_schema.json — test-support package data, not read at runtime
|
|
623
|
+
└── ... # Flat parser: HomiePropertyAccumulator, HomieLifecycle,
|
|
593
624
|
# HomieDeviceConsumer, field metadata, SCHEMA_ANCHOR
|
|
594
625
|
|
|
595
626
|
packages/schema-1/ # distribution: span-panel-api-schema-1
|
|
596
|
-
├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed
|
|
627
|
+
├── spec/ # eBus capability catalogs, byte-copied from the emitter wheel; checked against, never parsed
|
|
597
628
|
└── src/span_panel_api_schema_1/
|
|
598
|
-
|
|
629
|
+
├── reference/ # parent_child_tree.json — test-support package data, not read at runtime
|
|
630
|
+
└── ... # Parent/child parser: ControllerRoutes, snapshot mapper,
|
|
599
631
|
# adoption, catalog validator, spec_lock.json
|
|
600
632
|
```
|
|
601
633
|
|
|
@@ -393,9 +393,25 @@ context = build_panel_ssl_context(stored_pem)
|
|
|
393
393
|
fingerprint = ca_fingerprint(stored_pem)
|
|
394
394
|
```
|
|
395
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
|
|
397
|
-
|
|
398
|
-
|
|
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, in two steps — a rotated CA, an
|
|
397
|
+
expired leaf and a panel that has moved all raise the identical error, and the failed handshake carries no evidence about which.
|
|
398
|
+
|
|
399
|
+
First the library refetches the advertised CA, for comparison only. If the fingerprint has changed it raises `SpanPanelCAChangedError` carrying both fingerprints and stops. Register `register_fatal_error_callback` to be told; a consumer that registers
|
|
400
|
+
nothing still cannot mistake a dead bridge for a healthy one, because `ping()` and `get_snapshot()` re-raise.
|
|
401
|
+
|
|
402
|
+
If the fingerprint matches, the panel is still the panel and the library asks one further question: a second handshake to the broker with hostname checking relaxed — the chain, the signature and the expiry still verified against the pin — to see whether
|
|
403
|
+
the certificate names the address being dialled.
|
|
404
|
+
|
|
405
|
+
```python
|
|
406
|
+
def moved(mismatch: LeafNameMismatch) -> None:
|
|
407
|
+
print(f"configured as {mismatch.host}, certificate names {', '.join(mismatch.leaf_names)}")
|
|
408
|
+
|
|
409
|
+
unregister = client.register_leaf_mismatch_callback(moved)
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
**This is not fatal and the transport keeps retrying**, because a returning DHCP lease fixes it without anyone's help; what the callback is for is putting the other remedy — re-point the configuration at one of the names reported — in front of a user who
|
|
413
|
+
would otherwise see only an outage. It fires at most once per outage and is re-armed by the next successful connect. An expired leaf reports nothing, because nothing anyone does helps and the panel recovers on its own once it has the time again. Neither
|
|
414
|
+
handshake can re-anchor anything: both are diagnostic, and the pin is the pin whatever the panel served.
|
|
399
415
|
|
|
400
416
|
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
417
|
be fingerprint-confirmed out of band before it is trusted.
|
|
@@ -527,11 +543,25 @@ The `PanelCapability` flag enum advertises transport features at runtime:
|
|
|
527
543
|
|
|
528
544
|
## Reference Payloads
|
|
529
545
|
|
|
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 —
|
|
546
|
+
Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree — ship as package data of the adapter that parses each, and their provenance is documented in
|
|
547
|
+
[`tests/reference_payloads/`](tests/reference_payloads/README.md) beside the loaders that read them.
|
|
548
|
+
|
|
549
|
+
| Capture | Read from |
|
|
550
|
+
| ------------------------ | ---------------------------------------------------------- |
|
|
551
|
+
| `homie_schema.json` | `span_panel_api_schema_0/reference/homie_schema.json` |
|
|
552
|
+
| `parent_child_tree.json` | `span_panel_api_schema_1/reference/parent_child_tree.json` |
|
|
553
|
+
|
|
554
|
+
**Test-support data, and no runtime path reads either.** They ship so a downstream test suite pinned to a version of an adapter reads the same bytes that version was tested against, out of its own site-packages:
|
|
555
|
+
|
|
556
|
+
```python
|
|
557
|
+
from importlib.resources import files
|
|
558
|
+
import json
|
|
559
|
+
|
|
560
|
+
schema = json.loads((files("span_panel_api_schema_0") / "reference" / "homie_schema.json").read_text(encoding="utf-8"))
|
|
561
|
+
```
|
|
531
562
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
the copy fails loudly instead of testing against a schema no panel runs.
|
|
563
|
+
The bootstrap distribution ships neither: it registers no adapter and parses nothing. `span_panel_api.reference_payloads` and `span_panel_api_schema_1.reference_payloads` — the importable modules that existed until 3.1.0 — are still gone; these are data
|
|
564
|
+
files read through `importlib.resources`, not an import surface.
|
|
535
565
|
|
|
536
566
|
## Project Structure
|
|
537
567
|
|
|
@@ -562,13 +592,15 @@ src/span_panel_api/ # distribution: span-panel-api (no parser)
|
|
|
562
592
|
|
|
563
593
|
packages/schema-0/ # distribution: span-panel-api-schema-0
|
|
564
594
|
└── src/span_panel_api_schema_0/
|
|
565
|
-
|
|
595
|
+
├── reference/ # homie_schema.json — test-support package data, not read at runtime
|
|
596
|
+
└── ... # Flat parser: HomiePropertyAccumulator, HomieLifecycle,
|
|
566
597
|
# HomieDeviceConsumer, field metadata, SCHEMA_ANCHOR
|
|
567
598
|
|
|
568
599
|
packages/schema-1/ # distribution: span-panel-api-schema-1
|
|
569
|
-
├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed
|
|
600
|
+
├── spec/ # eBus capability catalogs, byte-copied from the emitter wheel; checked against, never parsed
|
|
570
601
|
└── src/span_panel_api_schema_1/
|
|
571
|
-
|
|
602
|
+
├── reference/ # parent_child_tree.json — test-support package data, not read at runtime
|
|
603
|
+
└── ... # Parent/child parser: ControllerRoutes, snapshot mapper,
|
|
572
604
|
# adoption, catalog validator, spec_lock.json
|
|
573
605
|
```
|
|
574
606
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "span-panel-api"
|
|
3
|
-
version = "3.
|
|
3
|
+
version = "3.3.0"
|
|
4
4
|
description = "A client library for SPAN Panel API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "SpanPanel"}
|
|
@@ -78,6 +78,12 @@ dev = [
|
|
|
78
78
|
# two distributions together, which is the configuration users will run.
|
|
79
79
|
"span-panel-api-schema-0",
|
|
80
80
|
"span-panel-api-schema-1",
|
|
81
|
+
# The producer of `span_panel_api_schema_1/reference/parent_child_tree.json`, pinned
|
|
82
|
+
# exactly because a reference capture is only evidence if what made it is
|
|
83
|
+
# known. Dependabot raises the bump on its own; the bump PR re-runs
|
|
84
|
+
# `scripts/capture_parent_child_reference.py` and the suite then says whether
|
|
85
|
+
# the wire moved.
|
|
86
|
+
"ebus-panel-sim==0.8.0",
|
|
81
87
|
"pytest>=9.0.2",
|
|
82
88
|
"pytest-asyncio>=1.3.0",
|
|
83
89
|
"pytest-cov",
|
|
@@ -102,7 +108,7 @@ dev = [
|
|
|
102
108
|
# transitive of `twine -> keyring -> secretstorage`, which is marked
|
|
103
109
|
# `sys_platform == 'linux'` -- so the whole module ran in CI and silently
|
|
104
110
|
# skipped on every macOS checkout, which reads in the summary line exactly
|
|
105
|
-
# like passing. See DEVELOPMENT.md, "A skip
|
|
111
|
+
# like passing. See DEVELOPMENT.md, "A skip is still not a pass".
|
|
106
112
|
#
|
|
107
113
|
# Floored at 50.0.0 rather than at whatever the lock happens to hold: four
|
|
108
114
|
# advisories cover the range below it (the highest first patched in 50.0.0),
|
|
@@ -153,8 +159,20 @@ include = [
|
|
|
153
159
|
|
|
154
160
|
[tool.ruff]
|
|
155
161
|
line-length = 125
|
|
162
|
+
# `scripts/` is excluded file by file rather than as a directory, so that a new
|
|
163
|
+
# script is linted by default and only the ones written before that was true are
|
|
164
|
+
# grandfathered out. `capture_parent_child_reference.py` is the one in scope: it
|
|
165
|
+
# produces a fixture the whole schema_1 suite is written against, and the rest are
|
|
166
|
+
# hand-run tools.
|
|
156
167
|
exclude = [
|
|
157
|
-
"scripts/",
|
|
168
|
+
"scripts/capture_flat_reference.py",
|
|
169
|
+
"scripts/capture_live_flat.py",
|
|
170
|
+
"scripts/coverage.py",
|
|
171
|
+
"scripts/format_markdown.py",
|
|
172
|
+
"scripts/test_live_auth.py",
|
|
173
|
+
"scripts/validate_lug_derivation/",
|
|
174
|
+
"scripts/verify_adapterless_install.py",
|
|
175
|
+
"scripts/verify_reconnect.py",
|
|
158
176
|
".*_cache/",
|
|
159
177
|
"dist/",
|
|
160
178
|
"venv/",
|
|
@@ -164,6 +182,10 @@ exclude = [
|
|
|
164
182
|
[tool.ruff.lint.per-file-ignores]
|
|
165
183
|
# Exclude tests from ALL linting checks (formatting still applies)
|
|
166
184
|
"tests/**/*.py" = ["ALL"]
|
|
185
|
+
# Its whole output is a report on stdout naming the producer, the device count and
|
|
186
|
+
# where the capture landed, which is what an operator reads to decide whether to
|
|
187
|
+
# adopt it. T20 is right for the library and wrong for a hand-run tool.
|
|
188
|
+
"scripts/capture_parent_child_reference.py" = ["T201"]
|
|
167
189
|
|
|
168
190
|
[tool.ruff.format]
|
|
169
191
|
quote-style = "double"
|
|
@@ -217,8 +239,18 @@ warn_unused_configs = true
|
|
|
217
239
|
disallow_untyped_defs = true
|
|
218
240
|
explicit_package_bases = true
|
|
219
241
|
mypy_path = "src"
|
|
242
|
+
# Excluded by name for the reason `[tool.ruff]` gives above: the capture script is
|
|
243
|
+
# checked like the library, and the hand-run tools written before that was expected
|
|
244
|
+
# are not.
|
|
220
245
|
exclude = [
|
|
221
|
-
"scripts/",
|
|
246
|
+
"scripts/capture_flat_reference.py",
|
|
247
|
+
"scripts/capture_live_flat.py",
|
|
248
|
+
"scripts/coverage.py",
|
|
249
|
+
"scripts/format_markdown.py",
|
|
250
|
+
"scripts/test_live_auth.py",
|
|
251
|
+
"scripts/validate_lug_derivation/",
|
|
252
|
+
"scripts/verify_adapterless_install.py",
|
|
253
|
+
"scripts/verify_reconnect.py",
|
|
222
254
|
"tests/",
|
|
223
255
|
"docs/",
|
|
224
256
|
".*_cache/",
|
|
@@ -6,7 +6,7 @@ supporting MQTT/Homie (v2) transport.
|
|
|
6
6
|
|
|
7
7
|
from importlib.metadata import version as _pkg_version
|
|
8
8
|
|
|
9
|
-
from ._ssl import build_panel_ssl_context, ca_fingerprint
|
|
9
|
+
from ._ssl import LeafNameMismatch, build_panel_ssl_context, ca_fingerprint, leaf_names_host
|
|
10
10
|
from .auth import (
|
|
11
11
|
delete_fqdn,
|
|
12
12
|
download_ca_cert,
|
|
@@ -154,6 +154,15 @@ __all__ = [ # noqa: RUF022
|
|
|
154
154
|
# both live here rather than being reimplemented on the other side.
|
|
155
155
|
"build_panel_ssl_context",
|
|
156
156
|
"ca_fingerprint",
|
|
157
|
+
# Added 2026-08-28: the hostname half of verification, split out so a
|
|
158
|
+
# caller using a relaxed context can still establish the name binding.
|
|
159
|
+
"leaf_names_host",
|
|
160
|
+
# Added 2026-08-28 (3.3.0): what the transport reports when the pinned CA
|
|
161
|
+
# validates the broker's certificate and that certificate names somewhere
|
|
162
|
+
# else. Purely additive -- a consumer that registers no leaf-mismatch
|
|
163
|
+
# callback never receives one, and the reconnect behaviour it accompanies is
|
|
164
|
+
# unchanged.
|
|
165
|
+
"LeafNameMismatch",
|
|
157
166
|
"delete_fqdn",
|
|
158
167
|
"download_ca_cert",
|
|
159
168
|
"get_fqdn",
|