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.
Files changed (109) hide show
  1. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/.gitignore +0 -5
  2. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/CHANGELOG.md +25 -0
  3. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/PKG-INFO +43 -11
  4. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/README.md +42 -10
  5. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/pyproject.toml +36 -4
  6. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/__init__.py +10 -1
  7. span_panel_api-3.3.0/src/span_panel_api/_ssl.py +364 -0
  8. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/exceptions.py +11 -3
  9. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/client.py +47 -0
  10. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/connection.py +153 -34
  11. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/const.py +8 -0
  12. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/protocol.py +25 -5
  13. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/conftest.py +7 -7
  14. span_panel_api-3.3.0/tests/fixtures/panelbench_wire.json +684 -0
  15. span_panel_api-3.3.0/tests/fixtures/v2/README.md +14 -0
  16. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/reference_payloads/README.md +27 -19
  17. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/reference_payloads/bootstrap.py +9 -4
  18. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/reference_payloads/schema_one.py +10 -5
  19. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_adoption.py +25 -1
  20. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_ca_pinning.py +35 -6
  21. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_catalog_divergence.py +16 -38
  22. span_panel_api-3.3.0/tests/test_leaf_name_mismatch.py +465 -0
  23. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_packaging.py +34 -32
  24. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_public_api_unchanged.py +13 -0
  25. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_reference_tree_values.py +7 -7
  26. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_migration_delta.py +11 -3
  27. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_adapter.py +1 -1
  28. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_circuits.py +43 -5
  29. span_panel_api-3.3.0/tests/test_schema_one_conformance.py +629 -0
  30. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_connection_health.py +2 -2
  31. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_control_refusal.py +137 -41
  32. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_discovery.py +1 -1
  33. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_panel.py +11 -11
  34. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_snapshot.py +4 -4
  35. span_panel_api-3.3.0/tests/test_ssl_context.py +326 -0
  36. span_panel_api-3.3.0/tests/tls_fixtures.py +246 -0
  37. span_panel_api-3.1.1/src/span_panel_api/_ssl.py +0 -104
  38. span_panel_api-3.1.1/tests/fixtures/v2/README.md +0 -14
  39. span_panel_api-3.1.1/tests/reference_payloads/homie_schema.json +0 -420
  40. span_panel_api-3.1.1/tests/reference_payloads/parent_child_tree.json +0 -234
  41. span_panel_api-3.1.1/tests/test_schema_one_against_simulator.py +0 -260
  42. span_panel_api-3.1.1/tests/test_schema_one_conformance.py +0 -828
  43. span_panel_api-3.1.1/tests/test_ssl_context.py +0 -234
  44. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/LICENSE +0 -0
  45. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/_http.py +0 -0
  46. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/adapters.py +0 -0
  47. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/auth.py +0 -0
  48. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/const.py +0 -0
  49. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/detection.py +0 -0
  50. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/dispatch.py +0 -0
  51. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/factory.py +0 -0
  52. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/models.py +0 -0
  53. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/__init__.py +0 -0
  54. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  55. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/control.py +0 -0
  56. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/models.py +0 -0
  57. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/phase_validation.py +0 -0
  58. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/py.typed +0 -0
  59. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/src/span_panel_api/schema_drift.py +0 -0
  60. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  61. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  62. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  63. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/fixtures/flat_wire.json +0 -0
  64. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  65. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/fixtures/v2/status.json +0 -0
  66. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/reference_payloads/__init__.py +0 -0
  67. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  68. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  69. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  70. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/simulation_fixtures/status.response.txt +0 -0
  71. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_absent_readings_are_not_zero.py +0 -0
  72. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_accumulator.py +0 -0
  73. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_adapters_discovery.py +0 -0
  74. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_adopted_control.py +0 -0
  75. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_async_mqtt_client.py +0 -0
  76. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_auth_and_homie_helpers.py +0 -0
  77. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_auth_redaction.py +0 -0
  78. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_control_interceptor.py +0 -0
  79. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_detection_auth.py +0 -0
  80. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_exceptions.py +0 -0
  81. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_factory_dispatch.py +0 -0
  82. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_field_metadata.py +0 -0
  83. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_https_transport.py +0 -0
  84. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_live_flat_differential.py +0 -0
  85. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_mqtt_bridge.py +0 -0
  86. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_mqtt_client_connection.py +0 -0
  87. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_mqtt_connect_flow.py +0 -0
  88. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_mqtt_debounce.py +0 -0
  89. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_mqtt_homie.py +0 -0
  90. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_phase_validation_configs.py +0 -0
  91. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_phase_validation_errors.py +0 -0
  92. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_plaintext_warning.py +0 -0
  93. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_protocol_conformance.py +0 -0
  94. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_protocol_models.py +0 -0
  95. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_publish_outcome.py +0 -0
  96. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_redispatch_on_reconnect.py +0 -0
  97. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_rest_transport_contract.py +0 -0
  98. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_generation_cross_check.py +0 -0
  99. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_charge_limit.py +0 -0
  100. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_devices.py +0 -0
  101. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_extension.py +0 -0
  102. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_pcs.py +0 -0
  103. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_service_entrance.py +0 -0
  104. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_shed_forecast.py +0 -0
  105. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_one_transport.py +0 -0
  106. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_provenance.py +0 -0
  107. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_schema_zero_adapter.py +0 -0
  108. {span_panel_api-3.1.1 → span_panel_api-3.3.0}/tests/test_shared_http_client.py +0 -0
  109. {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.1.1
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: 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.
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 — live in [`tests/reference_payloads/`](tests/reference_payloads/README.md), with their provenance.
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
- **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.
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
- # Flat parser: HomiePropertyAccumulator, HomieLifecycle,
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
- # Parent/child parser: ControllerRoutes, snapshot mapper,
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: 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.
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 — live in [`tests/reference_payloads/`](tests/reference_payloads/README.md), with their provenance.
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
- **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.
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
- # Flat parser: HomiePropertyAccumulator, HomieLifecycle,
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
- # Parent/child parser: ControllerRoutes, snapshot mapper,
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.1.1"
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 here is not a pass".
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",