span-panel-api 3.1.0__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 (108) hide show
  1. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/.gitignore +0 -5
  2. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/CHANGELOG.md +26 -0
  3. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/PKG-INFO +24 -8
  4. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/README.md +23 -7
  5. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/pyproject.toml +36 -4
  6. {span_panel_api-3.1.0 → 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/_http.py +318 -0
  8. span_panel_api-3.2.0/src/span_panel_api/_ssl.py +223 -0
  9. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/auth.py +235 -174
  10. span_panel_api-3.2.0/src/span_panel_api/detection.py +96 -0
  11. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/models.py +29 -0
  12. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/protocol.py +6 -5
  13. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/conftest.py +13 -7
  14. span_panel_api-3.2.0/tests/fixtures/panelbench_wire.json +684 -0
  15. span_panel_api-3.2.0/tests/fixtures/v2/README.md +14 -0
  16. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/reference_payloads/README.md +27 -19
  17. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/reference_payloads/bootstrap.py +9 -4
  18. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/reference_payloads/schema_one.py +10 -5
  19. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_adoption.py +25 -1
  20. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_auth_redaction.py +86 -1
  21. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_catalog_divergence.py +16 -38
  22. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_mqtt_homie.py +0 -14
  23. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_packaging.py +34 -32
  24. span_panel_api-3.2.0/tests/test_plaintext_warning.py +241 -0
  25. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_public_api_unchanged.py +6 -0
  26. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_reference_tree_values.py +7 -7
  27. span_panel_api-3.2.0/tests/test_rest_transport_contract.py +188 -0
  28. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_migration_delta.py +11 -3
  29. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_adapter.py +1 -1
  30. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_circuits.py +43 -5
  31. span_panel_api-3.2.0/tests/test_schema_one_conformance.py +629 -0
  32. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_connection_health.py +2 -2
  33. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_control_refusal.py +137 -41
  34. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_discovery.py +1 -1
  35. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_panel.py +11 -11
  36. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_snapshot.py +4 -4
  37. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_ssl_context.py +119 -1
  38. span_panel_api-3.2.0/tests/test_v2_status_parser.py +74 -0
  39. span_panel_api-3.1.0/src/span_panel_api/_http.py +0 -124
  40. span_panel_api-3.1.0/src/span_panel_api/_ssl.py +0 -104
  41. span_panel_api-3.1.0/src/span_panel_api/detection.py +0 -85
  42. span_panel_api-3.1.0/tests/fixtures/v2/README.md +0 -14
  43. span_panel_api-3.1.0/tests/reference_payloads/homie_schema.json +0 -420
  44. span_panel_api-3.1.0/tests/reference_payloads/parent_child_tree.json +0 -234
  45. span_panel_api-3.1.0/tests/test_schema_one_against_simulator.py +0 -260
  46. span_panel_api-3.1.0/tests/test_schema_one_conformance.py +0 -828
  47. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/LICENSE +0 -0
  48. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/adapters.py +0 -0
  49. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/const.py +0 -0
  50. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/dispatch.py +0 -0
  51. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/exceptions.py +0 -0
  52. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/factory.py +0 -0
  53. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/__init__.py +0 -0
  54. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  55. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/client.py +0 -0
  56. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/connection.py +0 -0
  57. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/const.py +0 -0
  58. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/control.py +0 -0
  59. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/models.py +0 -0
  60. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/phase_validation.py +0 -0
  61. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/py.typed +0 -0
  62. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/schema_drift.py +0 -0
  63. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  64. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  65. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  66. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/fixtures/flat_wire.json +0 -0
  67. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  68. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/fixtures/v2/status.json +0 -0
  69. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/reference_payloads/__init__.py +0 -0
  70. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  71. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  72. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  73. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/simulation_fixtures/status.response.txt +0 -0
  74. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_absent_readings_are_not_zero.py +0 -0
  75. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_accumulator.py +0 -0
  76. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_adapters_discovery.py +0 -0
  77. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_adopted_control.py +0 -0
  78. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_async_mqtt_client.py +0 -0
  79. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_auth_and_homie_helpers.py +0 -0
  80. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_ca_pinning.py +0 -0
  81. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_control_interceptor.py +0 -0
  82. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_detection_auth.py +0 -0
  83. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_exceptions.py +0 -0
  84. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_factory_dispatch.py +0 -0
  85. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_field_metadata.py +0 -0
  86. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_https_transport.py +0 -0
  87. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_live_flat_differential.py +0 -0
  88. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_mqtt_bridge.py +0 -0
  89. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_mqtt_client_connection.py +0 -0
  90. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_mqtt_connect_flow.py +0 -0
  91. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_mqtt_debounce.py +0 -0
  92. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_phase_validation_configs.py +0 -0
  93. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_phase_validation_errors.py +0 -0
  94. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_protocol_conformance.py +0 -0
  95. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_protocol_models.py +0 -0
  96. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_publish_outcome.py +0 -0
  97. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_redispatch_on_reconnect.py +0 -0
  98. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_generation_cross_check.py +0 -0
  99. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_charge_limit.py +0 -0
  100. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_devices.py +0 -0
  101. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_extension.py +0 -0
  102. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_pcs.py +0 -0
  103. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_service_entrance.py +0 -0
  104. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_shed_forecast.py +0 -0
  105. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_transport.py +0 -0
  106. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_provenance.py +0 -0
  107. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_zero_adapter.py +0 -0
  108. {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_shared_http_client.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,32 @@ 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
+
20
+ ## [3.1.1]
21
+
22
+ A follow-up to 3.1.0's security work, with no API change and no adapter move required.
23
+
24
+ ### Fixed
25
+
26
+ - **A rejected passphrase no longer reaches the debug log**, closing the shape of validation response that reports the field that failed in one place and the value it rejected in another, under a key that says nothing about what it holds.
27
+ - **A panel that fails part-way through answering is reported as unreachable**, rather than as an error from the HTTP layer that a consumer catching this library's own errors would not catch.
28
+ - **A response this library cannot read is reported as an API error naming the endpoint and the missing field**, instead of a raw parsing error raised out of the call.
29
+ - **`get_v2_status` reports whether the panel proved proximity**, which until now only the detection path had read, so the same panel answered differently depending on which call had asked.
30
+
31
+ ### Added
32
+
33
+ - **A warning when a panel's bootstrap traffic is unencrypted**, raised by the transport so that every call carrying a credential is covered, logged once per panel, never repeating the credential it warns about, and leaving plaintext the default it has
34
+ always been.
35
+
10
36
  ## [3.1.0]
11
37
 
12
38
  A security release. Three things a caller could not previously find out — whether a control command was delivered, whether the panel's bootstrap traffic was encrypted, and whether the CA behind the MQTT broker is still the one that was there yesterday —
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 3.1.0
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.0"
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,318 @@
1
+ """Shared HTTP helpers for SPAN Panel bootstrap REST calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncIterator
7
+ from contextlib import asynccontextmanager
8
+ from dataclasses import dataclass, field
9
+ import logging
10
+ import ssl
11
+ from typing import Literal
12
+
13
+ import httpx
14
+
15
+ from .exceptions import SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError, SpanPanelValidationError
16
+
17
+ _LOGGER = logging.getLogger(__name__)
18
+
19
+ #: What a bootstrap URL resolves to when the caller names no port. HTTP without a
20
+ #: context, HTTPS with one -- so a caller that pins the panel CA and leaves the
21
+ #: port alone reaches the right place rather than the plaintext one.
22
+ DEFAULT_HTTP_PORT = 80
23
+ DEFAULT_HTTPS_PORT = 443
24
+
25
+ #: The one bootstrap path two modules request: the detector probes it to decide
26
+ #: whether the panel speaks v2 at all, and `get_v2_status` reads the same answer
27
+ #: for a caller that already knows it does. Named here rather than spelled out in
28
+ #: each, so the two cannot drift apart the way their parsers had.
29
+ V2_STATUS_PATH = "/api/v2/status"
30
+
31
+ #: The verbs the bootstrap API uses. Spelled as a `Literal` rather than passed
32
+ #: through to `client.request()` so the dispatch below stays exhaustive and each
33
+ #: call still reaches the named httpx method.
34
+ type _Method = Literal["GET", "POST", "PUT", "DELETE"]
35
+
36
+
37
+ @dataclass
38
+ class _SSLCache:
39
+ """Mutable container for the cached SSLContext and its async lock."""
40
+
41
+ context: ssl.SSLContext | None = None
42
+ lock: asyncio.Lock | None = field(default=None, repr=False)
43
+
44
+ def get_lock(self) -> asyncio.Lock:
45
+ """Return the async lock, creating it lazily."""
46
+ if self.lock is None:
47
+ self.lock = asyncio.Lock()
48
+ return self.lock
49
+
50
+
51
+ _ssl_cache = _SSLCache()
52
+
53
+
54
+ def _build_url(host: str, port: int | None, path: str, ssl_context: ssl.SSLContext | None = None) -> str:
55
+ """Build a bootstrap URL, choosing the scheme from whether a CA was supplied.
56
+
57
+ ``port`` is ``None`` for "the caller did not say", which is the whole reason
58
+ it is nullable: with a plain ``int = 80`` there is no way to tell an omitted
59
+ port from one the caller deliberately set to 80, and the two need opposite
60
+ answers here.
61
+
62
+ An explicit ``port=80`` alongside an ``ssl_context`` is refused rather than
63
+ reinterpreted. It is not a hypothetical combination -- a consumer that
64
+ persisted a port before it pinned a CA produces exactly this on its first
65
+ HTTPS call -- and both readings are defensible: the caller may mean "HTTPS on
66
+ the unusual port 80" or may simply not have migrated the stored value.
67
+ Guessing either way is a security control that silently does something other
68
+ than what it was asked to.
69
+
70
+ Raises:
71
+ SpanPanelValidationError: ``ssl_context`` supplied with an explicit port 80.
72
+ """
73
+ if ssl_context is None:
74
+ resolved = DEFAULT_HTTP_PORT if port is None else port
75
+ return f"http://{host}{path}" if resolved == DEFAULT_HTTP_PORT else f"http://{host}:{resolved}{path}"
76
+
77
+ if port == DEFAULT_HTTP_PORT:
78
+ raise SpanPanelValidationError(
79
+ f"port={DEFAULT_HTTP_PORT} was passed together with an ssl_context for {host}. "
80
+ f"Port {DEFAULT_HTTP_PORT} is the plaintext default and {DEFAULT_HTTPS_PORT} is the TLS one; "
81
+ "pass the panel's HTTPS port explicitly, or omit port to take the default."
82
+ )
83
+ resolved = DEFAULT_HTTPS_PORT if port is None else port
84
+ return f"https://{host}{path}" if resolved == DEFAULT_HTTPS_PORT else f"https://{host}:{resolved}{path}"
85
+
86
+
87
+ async def _create_ssl_context() -> ssl.SSLContext:
88
+ """Return a cached default SSL context, creating it in an executor on first call.
89
+
90
+ ``ssl.create_default_context()`` calls ``load_verify_locations`` which
91
+ performs blocking file I/O on the system CA bundle. The resulting context
92
+ is thread-safe and reusable, so we cache it for the lifetime of the process.
93
+ """
94
+ cached = _ssl_cache.context
95
+ if cached is not None:
96
+ return cached
97
+ async with _ssl_cache.get_lock():
98
+ # Double-check after acquiring the lock.
99
+ cached = _ssl_cache.context
100
+ if cached is not None:
101
+ return cached
102
+ # Read back through a local rather than returning the field again. The
103
+ # field is `SSLContext | None` and another task may clear or replace it
104
+ # between the assignment and the return, so returning it a second time
105
+ # is a read this function cannot promise is non-None -- which is what a
106
+ # strict checker objects to, correctly. The value that was just built is
107
+ # the value to hand back.
108
+ loop = asyncio.get_running_loop()
109
+ context = await loop.run_in_executor(None, ssl.create_default_context)
110
+ _ssl_cache.context = context
111
+ return context
112
+
113
+
114
+ @asynccontextmanager
115
+ async def _get_client(
116
+ httpx_client: httpx.AsyncClient | None,
117
+ timeout: float,
118
+ ssl_context: ssl.SSLContext | None = None,
119
+ ) -> AsyncIterator[httpx.AsyncClient]:
120
+ """Yield the client this call should use, honouring both arguments truthfully.
121
+
122
+ **A supplied ``ssl_context`` wins over an injected client, deliberately.**
123
+ httpx fixes ``verify=`` at construction, so a context cannot be applied to a
124
+ client somebody else built. This function used to yield an injected client
125
+ untouched, which meant a caller passing both would have got a plaintext-or-
126
+ system-trust connection while believing it had pinned the panel CA -- a
127
+ security control that appears to be on and is off. The only two honest
128
+ options are to refuse the combination or to build a dedicated client, and
129
+ building one keeps the pin working for the consumer that motivated it: Home
130
+ Assistant injects its shared client at every bootstrap call site.
131
+
132
+ The cost is named rather than hidden: these calls lose the shared connection
133
+ pool and the injected client's timeout and header policy, and take this
134
+ function's ``timeout`` instead. Acceptable because every caller here is
135
+ bootstrap -- registration, detection, schema, FQDN, status -- made a handful
136
+ of times per config entry, not a hot path. The injected client is never
137
+ closed by this function on any path; the dedicated one always is.
138
+ """
139
+ if ssl_context is not None:
140
+ async with httpx.AsyncClient(timeout=timeout, verify=ssl_context) as client:
141
+ yield client
142
+ return
143
+ if httpx_client is not None:
144
+ yield httpx_client
145
+ return
146
+ ctx = await _create_ssl_context()
147
+ async with httpx.AsyncClient(timeout=timeout, verify=ctx) as client:
148
+ yield client
149
+
150
+
151
+ #: Panels already warned about over plaintext, so the warning is said once each.
152
+ #: Process-wide, once per panel host: wider than the MQTT bridge's unpinned-CA
153
+ #: warning, which is per bridge instance and so repeats when a config entry is
154
+ #: reloaded. See `_warn_plaintext_transport` for why the client object is the
155
+ #: wrong key.
156
+ _warned_plaintext_hosts: set[str] = set()
157
+
158
+
159
+ def _reset_plaintext_warnings() -> None:
160
+ """Test hook. Not public API."""
161
+ _warned_plaintext_hosts.clear()
162
+
163
+
164
+ def _warn_plaintext_transport(host: str, ssl_context: ssl.SSLContext | None) -> None:
165
+ """Say out loud, once per panel, that its bootstrap traffic is not encrypted.
166
+
167
+ In the same voice as the MQTT bridge's unpinned-CA warning, and for the same
168
+ reason: a security property that is off by default is only a decision if the
169
+ operator can tell it is off. ``ssl_context=None`` puts the request on
170
+ plaintext ``http://``, and two of these calls carry credentials --
171
+ registration sends the panel passphrase and brings the broker password back,
172
+ and passphrase rotation sends a bearer token and brings the new broker
173
+ password back -- so anything on the path reads all of it.
174
+
175
+ **Called from the transport, not from the calls that bootstrap a client.**
176
+ Warning at the call sites meant each new call site had to remember to, and
177
+ passphrase rotation did not: the one call a consumer reaches for when
178
+ reauthenticating went out in the clear and said nothing. There is one
179
+ mechanism here so there is nothing to remember.
180
+
181
+ **Scoped to the panel, not to the request or the client object.** Per
182
+ request is a line somebody filters out. Per client object looks tighter and
183
+ is worse, because the CA download runs on every MQTT reconnect and builds a
184
+ fresh client each time -- so that key would produce a warning per reconnect,
185
+ which is precisely what the bridge's own once-per-bridge warning exists to
186
+ avoid. The panel is the thing the warning is actually about.
187
+
188
+ The credential itself is never named. This is a warning *about* a secret,
189
+ not a place to put one.
190
+ """
191
+ if ssl_context is not None:
192
+ return
193
+ if host in _warned_plaintext_hosts:
194
+ return
195
+ _warned_plaintext_hosts.add(host)
196
+ _LOGGER.warning(
197
+ "Bootstrap traffic for %s is being sent over plaintext HTTP: no ssl_context was supplied, so "
198
+ "these requests and their responses -- including any credential they carry, such as the panel "
199
+ "passphrase and the broker password -- are readable by anything on the path between here and "
200
+ "the panel. Pin the panel's CA certificate and pass it as ssl_context.",
201
+ host,
202
+ )
203
+
204
+
205
+ @dataclass(frozen=True, slots=True)
206
+ class _Reply:
207
+ """One panel answer, with the decoding every caller of it needs.
208
+
209
+ Status classification stays with the caller, because it is genuinely
210
+ per-endpoint: 412 means "no passphrase is set" on the rotation path and
211
+ nothing anywhere else, and 404 means "no FQDN configured" on one call and
212
+ "not a v2 panel" on another. The two steps *around* that classification are
213
+ the same everywhere and had been written out once per endpoint -- translating
214
+ a failed connection, and turning a body into an object with the fields the
215
+ caller is about to read. Both live here.
216
+ """
217
+
218
+ host: str
219
+ endpoint: str
220
+ response: httpx.Response
221
+
222
+ @property
223
+ def status_code(self) -> int:
224
+ """The status the panel answered with."""
225
+ return self.response.status_code
226
+
227
+ @property
228
+ def text(self) -> str:
229
+ """The body as text, for the one endpoint that answers with a PEM."""
230
+ return self.response.text
231
+
232
+ @property
233
+ def headers(self) -> httpx.Headers:
234
+ """The response headers, for ``Retry-After`` and content-type."""
235
+ return self.response.headers
236
+
237
+ def json_object(self, *required: str, on_malformed: type[SpanPanelAPIError] = SpanPanelAPIError) -> dict[str, object]:
238
+ """Decode the body as a JSON object and confirm the fields about to be read.
239
+
240
+ A 200 is not a promise of a body. A panel part-way through starting
241
+ answers one with nothing in it; a proxy in front of one answers with an
242
+ HTML error page under a 200; and firmware is free to omit a field this
243
+ library treats as mandatory. Untranslated those surfaced as
244
+ ``JSONDecodeError`` and ``KeyError`` -- neither of them a
245
+ ``SpanPanelError``, so neither caught by a caller holding this library's
246
+ contract, and both escaping the retry clauses built on it.
247
+
248
+ ``on_malformed`` exists for the one endpoint where an unreadable body is
249
+ "not ready yet" rather than "wrong": the schema fetch, whose caller
250
+ retries a booting panel. Every other endpoint here is asked once.
251
+ """
252
+ try:
253
+ parsed = self.response.json()
254
+ except ValueError as exc:
255
+ raise on_malformed(
256
+ f"{self.host} answered HTTP {self.status_code} for {self.endpoint} with a body that is not JSON",
257
+ status_code=self.status_code,
258
+ ) from exc
259
+ if not isinstance(parsed, dict):
260
+ raise on_malformed(
261
+ f"{self.host} answered HTTP {self.status_code} for {self.endpoint} "
262
+ f"with {type(parsed).__name__}, not a JSON object",
263
+ status_code=self.status_code,
264
+ )
265
+ body: dict[str, object] = parsed
266
+ missing = sorted(key for key in required if key not in body)
267
+ if missing:
268
+ raise on_malformed(
269
+ f"{self.host} answered HTTP {self.status_code} for {self.endpoint} "
270
+ f"without the required field(s) {', '.join(missing)}",
271
+ status_code=self.status_code,
272
+ )
273
+ return body
274
+
275
+
276
+ async def _request(
277
+ method: _Method,
278
+ host: str,
279
+ port: int | None,
280
+ path: str,
281
+ *,
282
+ timeout: float,
283
+ httpx_client: httpx.AsyncClient | None = None,
284
+ ssl_context: ssl.SSLContext | None = None,
285
+ json: dict[str, str] | None = None,
286
+ headers: dict[str, str] | None = None,
287
+ ) -> _Reply:
288
+ """Make one bootstrap request and translate everything that is not an answer.
289
+
290
+ The whole ``httpx.TransportError`` family, not just a refused connect:
291
+ ``ReadError`` and ``WriteError`` when a rebooting panel resets mid-request,
292
+ and ``RemoteProtocolError`` when its proxy closes without answering, which is
293
+ what a proxy restarting under load produces. ``TimeoutException`` is itself a
294
+ ``TransportError``, so it has to be caught first to keep its own class.
295
+
296
+ The verb is dispatched to the named httpx method rather than handed to
297
+ ``client.request()``: the URL stays the first positional argument of a
298
+ recognisable call, which is what makes an injected client inspectable by the
299
+ caller that supplied it.
300
+ """
301
+ url = _build_url(host, port, path, ssl_context)
302
+ _warn_plaintext_transport(host, ssl_context)
303
+ try:
304
+ async with _get_client(httpx_client, timeout, ssl_context) as client:
305
+ match method:
306
+ case "GET":
307
+ response = await client.get(url, headers=headers)
308
+ case "POST":
309
+ response = await client.post(url, json=json, headers=headers)
310
+ case "PUT":
311
+ response = await client.put(url, json=json, headers=headers)
312
+ case "DELETE":
313
+ response = await client.delete(url, headers=headers)
314
+ except httpx.TimeoutException as exc:
315
+ raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc
316
+ except httpx.TransportError as exc:
317
+ raise SpanPanelConnectionError(f"Cannot reach panel at {host}: {exc}") from exc
318
+ return _Reply(host=host, endpoint=path, response=response)