span-panel-api 3.1.1__tar.gz → 3.2.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 (106) hide show
  1. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/.gitignore +0 -5
  2. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/CHANGELOG.md +10 -0
  3. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/PKG-INFO +24 -8
  4. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/README.md +23 -7
  5. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/pyproject.toml +36 -4
  6. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/__init__.py +4 -1
  7. span_panel_api-3.2.0/src/span_panel_api/_ssl.py +223 -0
  8. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/protocol.py +6 -5
  9. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/conftest.py +7 -7
  10. span_panel_api-3.2.0/tests/fixtures/panelbench_wire.json +684 -0
  11. span_panel_api-3.2.0/tests/fixtures/v2/README.md +14 -0
  12. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/reference_payloads/README.md +27 -19
  13. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/reference_payloads/bootstrap.py +9 -4
  14. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/reference_payloads/schema_one.py +10 -5
  15. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_adoption.py +25 -1
  16. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_catalog_divergence.py +16 -38
  17. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_packaging.py +34 -32
  18. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_public_api_unchanged.py +6 -0
  19. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_reference_tree_values.py +7 -7
  20. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_migration_delta.py +11 -3
  21. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_adapter.py +1 -1
  22. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_circuits.py +43 -5
  23. span_panel_api-3.2.0/tests/test_schema_one_conformance.py +629 -0
  24. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_connection_health.py +2 -2
  25. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_control_refusal.py +137 -41
  26. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_discovery.py +1 -1
  27. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_panel.py +11 -11
  28. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_snapshot.py +4 -4
  29. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_ssl_context.py +119 -1
  30. span_panel_api-3.1.1/src/span_panel_api/_ssl.py +0 -104
  31. span_panel_api-3.1.1/tests/fixtures/v2/README.md +0 -14
  32. span_panel_api-3.1.1/tests/reference_payloads/homie_schema.json +0 -420
  33. span_panel_api-3.1.1/tests/reference_payloads/parent_child_tree.json +0 -234
  34. span_panel_api-3.1.1/tests/test_schema_one_against_simulator.py +0 -260
  35. span_panel_api-3.1.1/tests/test_schema_one_conformance.py +0 -828
  36. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/LICENSE +0 -0
  37. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/_http.py +0 -0
  38. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/adapters.py +0 -0
  39. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/auth.py +0 -0
  40. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/const.py +0 -0
  41. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/detection.py +0 -0
  42. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/dispatch.py +0 -0
  43. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/exceptions.py +0 -0
  44. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/factory.py +0 -0
  45. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/models.py +0 -0
  46. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/__init__.py +0 -0
  47. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  48. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/client.py +0 -0
  49. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/connection.py +0 -0
  50. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/const.py +0 -0
  51. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/control.py +0 -0
  52. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/models.py +0 -0
  53. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/phase_validation.py +0 -0
  54. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/py.typed +0 -0
  55. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/schema_drift.py +0 -0
  56. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  57. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  58. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  59. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/fixtures/flat_wire.json +0 -0
  60. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  61. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/fixtures/v2/status.json +0 -0
  62. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/reference_payloads/__init__.py +0 -0
  63. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  64. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  65. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  66. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/simulation_fixtures/status.response.txt +0 -0
  67. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_absent_readings_are_not_zero.py +0 -0
  68. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_accumulator.py +0 -0
  69. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_adapters_discovery.py +0 -0
  70. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_adopted_control.py +0 -0
  71. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_async_mqtt_client.py +0 -0
  72. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_auth_and_homie_helpers.py +0 -0
  73. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_auth_redaction.py +0 -0
  74. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_ca_pinning.py +0 -0
  75. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_control_interceptor.py +0 -0
  76. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_detection_auth.py +0 -0
  77. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_exceptions.py +0 -0
  78. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_factory_dispatch.py +0 -0
  79. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_field_metadata.py +0 -0
  80. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_https_transport.py +0 -0
  81. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_live_flat_differential.py +0 -0
  82. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_mqtt_bridge.py +0 -0
  83. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_mqtt_client_connection.py +0 -0
  84. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_mqtt_connect_flow.py +0 -0
  85. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_mqtt_debounce.py +0 -0
  86. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_mqtt_homie.py +0 -0
  87. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_phase_validation_configs.py +0 -0
  88. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_phase_validation_errors.py +0 -0
  89. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_plaintext_warning.py +0 -0
  90. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_protocol_conformance.py +0 -0
  91. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_protocol_models.py +0 -0
  92. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_publish_outcome.py +0 -0
  93. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_redispatch_on_reconnect.py +0 -0
  94. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_rest_transport_contract.py +0 -0
  95. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_generation_cross_check.py +0 -0
  96. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_charge_limit.py +0 -0
  97. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_devices.py +0 -0
  98. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_extension.py +0 -0
  99. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_pcs.py +0 -0
  100. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_service_entrance.py +0 -0
  101. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_shed_forecast.py +0 -0
  102. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_transport.py +0 -0
  103. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_provenance.py +0 -0
  104. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_zero_adapter.py +0 -0
  105. {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_shared_http_client.py +0 -0
  106. {span_panel_api-3.1.1 → span_panel_api-3.2.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,16 @@ 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.2.0]
11
+
12
+ 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.
13
+
14
+ ### Added
15
+
16
+ - **`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.
17
+ - **`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
18
+ that never stand in for one another.
19
+
10
20
  ## [3.1.1]
11
21
 
12
22
  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.2.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
@@ -554,11 +554,25 @@ The `PanelCapability` flag enum advertises transport features at runtime:
554
554
 
555
555
  ## Reference Payloads
556
556
 
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.
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 — ship as package data of the adapter that parses each, and their provenance is documented in
558
+ [`tests/reference_payloads/`](tests/reference_payloads/README.md) beside the loaders that read them.
558
559
 
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.
560
+ | Capture | Read from |
561
+ | ------------------------ | ---------------------------------------------------------- |
562
+ | `homie_schema.json` | `span_panel_api_schema_0/reference/homie_schema.json` |
563
+ | `parent_child_tree.json` | `span_panel_api_schema_1/reference/parent_child_tree.json` |
564
+
565
+ **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:
566
+
567
+ ```python
568
+ from importlib.resources import files
569
+ import json
570
+
571
+ schema = json.loads((files("span_panel_api_schema_0") / "reference" / "homie_schema.json").read_text(encoding="utf-8"))
572
+ ```
573
+
574
+ 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
575
+ files read through `importlib.resources`, not an import surface.
562
576
 
563
577
  ## Project Structure
564
578
 
@@ -589,13 +603,15 @@ src/span_panel_api/ # distribution: span-panel-api (no parser)
589
603
 
590
604
  packages/schema-0/ # distribution: span-panel-api-schema-0
591
605
  └── src/span_panel_api_schema_0/
592
- # Flat parser: HomiePropertyAccumulator, HomieLifecycle,
606
+ ├── reference/ # homie_schema.json — test-support package data, not read at runtime
607
+ └── ... # Flat parser: HomiePropertyAccumulator, HomieLifecycle,
593
608
  # HomieDeviceConsumer, field metadata, SCHEMA_ANCHOR
594
609
 
595
610
  packages/schema-1/ # distribution: span-panel-api-schema-1
596
- ├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed
611
+ ├── spec/ # eBus capability catalogs, byte-copied from the emitter wheel; checked against, never parsed
597
612
  └── src/span_panel_api_schema_1/
598
- # Parent/child parser: ControllerRoutes, snapshot mapper,
613
+ ├── reference/ # parent_child_tree.json — test-support package data, not read at runtime
614
+ └── ... # Parent/child parser: ControllerRoutes, snapshot mapper,
599
615
  # adoption, catalog validator, spec_lock.json
600
616
  ```
601
617
 
@@ -527,11 +527,25 @@ The `PanelCapability` flag enum advertises transport features at runtime:
527
527
 
528
528
  ## Reference Payloads
529
529
 
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.
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 — ship as package data of the adapter that parses each, and their provenance is documented in
531
+ [`tests/reference_payloads/`](tests/reference_payloads/README.md) beside the loaders that read them.
531
532
 
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.
533
+ | Capture | Read from |
534
+ | ------------------------ | ---------------------------------------------------------- |
535
+ | `homie_schema.json` | `span_panel_api_schema_0/reference/homie_schema.json` |
536
+ | `parent_child_tree.json` | `span_panel_api_schema_1/reference/parent_child_tree.json` |
537
+
538
+ **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:
539
+
540
+ ```python
541
+ from importlib.resources import files
542
+ import json
543
+
544
+ schema = json.loads((files("span_panel_api_schema_0") / "reference" / "homie_schema.json").read_text(encoding="utf-8"))
545
+ ```
546
+
547
+ 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
548
+ files read through `importlib.resources`, not an import surface.
535
549
 
536
550
  ## Project Structure
537
551
 
@@ -562,13 +576,15 @@ src/span_panel_api/ # distribution: span-panel-api (no parser)
562
576
 
563
577
  packages/schema-0/ # distribution: span-panel-api-schema-0
564
578
  └── src/span_panel_api_schema_0/
565
- # Flat parser: HomiePropertyAccumulator, HomieLifecycle,
579
+ ├── reference/ # homie_schema.json — test-support package data, not read at runtime
580
+ └── ... # Flat parser: HomiePropertyAccumulator, HomieLifecycle,
566
581
  # HomieDeviceConsumer, field metadata, SCHEMA_ANCHOR
567
582
 
568
583
  packages/schema-1/ # distribution: span-panel-api-schema-1
569
- ├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed
584
+ ├── spec/ # eBus capability catalogs, byte-copied from the emitter wheel; checked against, never parsed
570
585
  └── src/span_panel_api_schema_1/
571
- # Parent/child parser: ControllerRoutes, snapshot mapper,
586
+ ├── reference/ # parent_child_tree.json — test-support package data, not read at runtime
587
+ └── ... # Parent/child parser: ControllerRoutes, snapshot mapper,
572
588
  # adoption, catalog validator, spec_lock.json
573
589
  ```
574
590
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "3.1.1"
3
+ version = "3.2.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 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,9 @@ __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",
157
160
  "delete_fqdn",
158
161
  "download_ca_cert",
159
162
  "get_fqdn",
@@ -0,0 +1,223 @@
1
+ """The panel's trust anchor: building a context from it, and naming it.
2
+
3
+ Both functions here take a CA in PEM form and nothing else. They make no network
4
+ call and hold no state, which is the point -- a trust anchor that is fetched at
5
+ the moment it is used is not an anchor, it is whatever answered. The fetching
6
+ lives in ``auth.download_ca_cert``, and deciding whether a fetched PEM may be
7
+ trusted lives with the caller.
8
+
9
+ Public rather than private (``_ssl`` is a module-name convention here, and every
10
+ name is re-exported from the package root) because the consumer needs all three:
11
+ it builds the same context for its own HTTPS calls, it prints and compares the
12
+ same fingerprint string, and it applies the same hostname rules when it has to
13
+ judge a name binding for itself. Two implementations of a fingerprint that must
14
+ agree byte-for-byte is a defect waiting for a firmware upgrade to find it, and
15
+ the same is true of a hand-written hostname matcher -- more so, since that one
16
+ is security-relevant and has no standard-library implementation left to defer
17
+ to since ``ssl.match_hostname`` was removed in Python 3.12.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import binascii
24
+ from collections.abc import Iterator, Mapping
25
+ import hashlib
26
+ import ipaddress
27
+ import ssl
28
+
29
+ from .exceptions import SpanPanelValidationError
30
+
31
+ _PEM_HEADER = "-----BEGIN CERTIFICATE-----"
32
+ _PEM_FOOTER = "-----END CERTIFICATE-----"
33
+
34
+
35
+ def build_panel_ssl_context(ca_pem: str, *, check_hostname: bool = True) -> ssl.SSLContext:
36
+ """Build an SSLContext that trusts only the provided panel CA.
37
+
38
+ The panel issues a private CA and a server cert signed by it. We do
39
+ not want to trust system CAs for this connection, so the context is
40
+ built fresh rather than via ``ssl.create_default_context()``.
41
+
42
+ The panel's CA is a minimal self-signed certificate that omits the
43
+ Authority Key Identifier (AKI) X.509v3 extension. Python 3.13 enabled
44
+ ``VERIFY_X509_STRICT`` by default, and that flag rejects such a
45
+ certificate with "Missing Authority Key Identifier", which makes the
46
+ MQTTS handshake fail on otherwise healthy panels. The flag is cleared
47
+ here so the library keeps working across Python versions.
48
+
49
+ This does not weaken the parts of verification that matter for this
50
+ connection: the trust anchor is still only the panel's own CA, hostname
51
+ checking stays enabled by default, and signature/expiry validation is
52
+ unchanged.
53
+
54
+ ``check_hostname=False`` asks a narrower question: *does the peer hold a
55
+ private key whose certificate chains to this anchor?* The chain, the
56
+ signature and the expiry are still verified -- only the binding between
57
+ the certificate and the name used to dial it is left unasserted. That is
58
+ a real distinction and not a relaxation of trust: an attacker without a
59
+ CA-signed key cannot complete the handshake either way.
60
+
61
+ It exists because the two failures are otherwise indistinguishable, and
62
+ they call for opposite responses. A panel that has moved to a new DHCP
63
+ lease serves a perfectly good certificate that no longer names the
64
+ address it is reached at; something impersonating a panel serves one that
65
+ chains to nothing. Collapsing both into "verification failed" tells a
66
+ user their panel has been intercepted when its address merely changed.
67
+
68
+ Never pass ``check_hostname=False`` for a connection that carries data.
69
+ The name binding is what stops a validated certificate being replayed by
70
+ a host it was not issued to, so a relaxed context belongs only in code
71
+ that is deciding *which* host to talk to, paired with
72
+ :func:`leaf_names_host` to establish the binding separately.
73
+
74
+ Raises:
75
+ ssl.SSLError: ``ca_pem`` is not a certificate the ssl module accepts.
76
+ ValueError: ``ca_pem`` is malformed in a way ``ssl`` reports as such.
77
+ """
78
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
79
+ ctx.verify_mode = ssl.CERT_REQUIRED
80
+ ctx.check_hostname = check_hostname
81
+ ctx.verify_flags &= ~ssl.VERIFY_X509_STRICT
82
+ ctx.load_verify_locations(cadata=ca_pem)
83
+ return ctx
84
+
85
+
86
+ def leaf_names_host(peer_cert: Mapping[str, object], host: str) -> bool:
87
+ """Whether a validated peer certificate names ``host`` in its SAN.
88
+
89
+ The hostname half of what ``check_hostname=True`` does in one step, split
90
+ out so a caller that built a relaxed context can still ask the question
91
+ and act on the answer. ``peer_cert`` is what ``SSLSocket.getpeercert()``
92
+ returns, which is populated only for a certificate the handshake already
93
+ validated -- so this function decides naming, never trust.
94
+
95
+ Hand-written because ``ssl.match_hostname`` was removed in Python 3.12
96
+ and nothing replaced it as public API. The rules here are deliberately
97
+ stricter than the ones it implemented, because a panel's leaf is
98
+ machine-generated from a fixed template and needs none of the latitude a
99
+ general-purpose matcher owes the public web:
100
+
101
+ - **No wildcards.** ``*.example.com`` is not matched against anything. A
102
+ panel names literal addresses, so a wildcard in one of its certificates
103
+ would be an anomaly rather than a case to support.
104
+ - **No ``commonName`` fallback.** Deprecated for two decades, and every
105
+ certificate this library meets carries a SAN.
106
+ - **IP and DNS entries are not interchangeable.** A host that parses as an
107
+ IP address is matched only against ``IP Address`` entries and a name
108
+ only against ``DNS`` entries, so a certificate naming the *string*
109
+ "10.0.0.5" in a DNS entry does not authorise the address 10.0.0.5.
110
+ - **Addresses compare parsed, names compare casefolded.** ``::1`` and
111
+ ``0:0:0:0:0:0:0:1`` are one address; ``Panel.local`` and ``panel.local``
112
+ are one name. A single trailing dot is insignificant on both sides.
113
+
114
+ Returns False for anything it cannot read -- a certificate with no SAN, a
115
+ malformed entry, an unparseable address. The caller's question is "may I
116
+ treat this name as bound to this certificate", and the honest answer to a
117
+ SAN that cannot be understood is no.
118
+ """
119
+ candidate = _without_root_dot(host)
120
+ if not candidate:
121
+ return False
122
+ entries = list(_san_entries(peer_cert))
123
+ try:
124
+ wanted = ipaddress.ip_address(candidate)
125
+ except ValueError:
126
+ return _names_dns(entries, candidate)
127
+ return _names_address(entries, wanted)
128
+
129
+
130
+ def _without_root_dot(name: str) -> str:
131
+ """Strip surrounding space and a single root dot, which is not significant."""
132
+ stripped = name.strip()
133
+ return stripped[:-1] if stripped.endswith(".") else stripped
134
+
135
+
136
+ def _san_entries(peer_cert: Mapping[str, object]) -> Iterator[tuple[str, str]]:
137
+ """Yield the readable ``(kind, value)`` pairs of a certificate's SAN.
138
+
139
+ Anything malformed is skipped rather than rejected wholesale, so one broken
140
+ entry cannot hide a good one sitting beside it.
141
+ """
142
+ san = peer_cert.get("subjectAltName")
143
+ if not isinstance(san, tuple | list):
144
+ return
145
+ for entry in san:
146
+ if not isinstance(entry, tuple | list) or len(entry) != 2:
147
+ continue
148
+ kind, value = entry
149
+ if isinstance(kind, str) and isinstance(value, str):
150
+ yield kind, value
151
+
152
+
153
+ def _names_address(entries: list[tuple[str, str]], wanted: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
154
+ """Whether an ``IP Address`` entry denotes ``wanted``, compared as addresses."""
155
+ for kind, value in entries:
156
+ if kind != "IP Address":
157
+ continue
158
+ try:
159
+ if ipaddress.ip_address(value.strip()) == wanted:
160
+ return True
161
+ except ValueError:
162
+ continue
163
+ return False
164
+
165
+
166
+ def _names_dns(entries: list[tuple[str, str]], candidate: str) -> bool:
167
+ """Whether a ``DNS`` entry equals ``candidate``, casefolded and exact."""
168
+ folded = candidate.casefold()
169
+ for kind, value in entries:
170
+ if kind != "DNS":
171
+ continue
172
+ named = _without_root_dot(value)
173
+ if named and named.casefold() == folded:
174
+ return True
175
+ return False
176
+
177
+
178
+ def ca_fingerprint(ca_pem: str) -> str:
179
+ """SHA-256 over the certificate's DER bytes, lowercase hex, no separators.
180
+
181
+ The identity of a trust anchor, in a form a user can compare by eye against
182
+ what the panel's label or another install reports, and a consumer can store
183
+ in a config entry.
184
+
185
+ Taken over the DER rather than over the PEM text on purpose. PEM is a
186
+ presentation of the same bytes -- line width, line endings, surrounding
187
+ blank lines and any explanatory text a firmware chooses to put above the
188
+ header all vary without the certificate changing -- so a hash of the text
189
+ would report a rotation that did not happen. That is the worse error of the
190
+ two available: an integration that raises "your panel's CA changed" every
191
+ time a firmware reflows its PEM teaches its users to dismiss the one time it
192
+ matters.
193
+
194
+ Only the first certificate in the PEM is read. The panel serves a single
195
+ self-signed CA; if a future firmware appends a chain, the anchor is still the
196
+ first element, and silently hashing a concatenation would change the
197
+ fingerprint of an unchanged anchor.
198
+
199
+ Raises:
200
+ SpanPanelValidationError: no certificate block, or one whose body is not
201
+ valid base64. Distinct from an ``ssl`` error because nothing has been
202
+ asked of ``ssl`` yet -- this is a malformed input, and the caller
203
+ handling it has a different remedy from one whose certificate is
204
+ well-formed and unacceptable.
205
+ """
206
+ start = ca_pem.find(_PEM_HEADER)
207
+ if start == -1:
208
+ raise SpanPanelValidationError("No PEM certificate block found; cannot fingerprint the CA")
209
+ body_start = start + len(_PEM_HEADER)
210
+ end = ca_pem.find(_PEM_FOOTER, body_start)
211
+ if end == -1:
212
+ raise SpanPanelValidationError("PEM certificate block is not terminated; cannot fingerprint the CA")
213
+
214
+ # Every run of whitespace is dropped rather than only line breaks, so a PEM
215
+ # reflowed, re-indented or converted to CRLF fingerprints identically.
216
+ body = "".join(ca_pem[body_start:end].split())
217
+ try:
218
+ der = base64.b64decode(body, validate=True)
219
+ except (binascii.Error, ValueError) as exc:
220
+ raise SpanPanelValidationError("PEM certificate body is not valid base64; cannot fingerprint the CA") from exc
221
+ if not der:
222
+ raise SpanPanelValidationError("PEM certificate block is empty; cannot fingerprint the CA")
223
+ return hashlib.sha256(der).hexdigest()
@@ -275,11 +275,12 @@ class SchemaAdapter(Protocol):
275
275
  under the flat schema, `$settable` on `load-shed/priority` under v1.0 --
276
276
  which is the same reading `SpanCircuitSnapshot.is_never_backup` reports.
277
277
 
278
- None also where the panel carries no circuit under that id, and where
279
- the device carries no shed priority to write: under v1.0 an absent
280
- `$settable` on a *declared* `load-shed/priority` means settable, but a
281
- device that declares no such property has offered no such control, and
282
- the two are not the same absence.
278
+ Under v1.0 the lock is announced by *omitting* `$settable`, which is
279
+ Homie 5's default for the attribute and what a conforming publisher
280
+ emits for a control that accepts no write. A device that declares no
281
+ `load-shed/priority` at all answers None for the plainer reason that it
282
+ has offered no such control -- as does a panel carrying no circuit under
283
+ that id.
283
284
  """
284
285
 
285
286
  def has_circuit(self, circuit_id: str) -> bool:
@@ -26,15 +26,15 @@ _DOTENV = Path(__file__).parent.parent / ".env"
26
26
  def _load_dotenv() -> None:
27
27
  """Populate the environment from `.env`, without overriding what is set.
28
28
 
29
- Read directly rather than through python-dotenv: this supplies developer
30
- defaults for the optional provenance checks (`EBUS_SPEC_DIR`,
31
- `PANELBENCH_DIR`), and taking a dependency to parse two lines would put a
32
- package in the test path to save nothing.
29
+ Read directly rather than through python-dotenv: this supplies the credentials
30
+ for the one check that needs a real panel (`LIVE_PANEL_*`), and taking a
31
+ dependency to parse four lines would put a package in the test path to save
32
+ nothing.
33
33
 
34
34
  `setdefault`, never assignment. An exported value is a deliberate choice for
35
- this run — pointing at a different checkout to reproduce something — and a
36
- file silently winning over it is the kind of surprise that costs an
37
- afternoon. See `.env.example`; absence is fine, the checks skip.
35
+ this run — pointing at a second panel to reproduce something — and a file
36
+ silently winning over it is the kind of surprise that costs an afternoon. See
37
+ `.env.example`; absence is fine, `test_live_flat_differential.py` skips.
38
38
  """
39
39
  if not _DOTENV.exists():
40
40
  return