span-panel-api 3.7.0b1__tar.gz → 3.8.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 (164) hide show
  1. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/.gitignore +1 -0
  2. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/CHANGELOG.md +14 -7
  3. span_panel_api-3.7.0b1/README.md → span_panel_api-3.8.0/PKG-INFO +61 -16
  4. span_panel_api-3.7.0b1/PKG-INFO → span_panel_api-3.8.0/README.md +34 -43
  5. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/pyproject.toml +14 -6
  6. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/__init__.py +38 -0
  7. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/auth.py +44 -13
  8. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/exceptions.py +19 -0
  9. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/models.py +275 -5
  10. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/mqtt/client.py +70 -4
  11. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/mqtt/control.py +1 -0
  12. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/protocol.py +29 -1
  13. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/adapter_fidelity/harness.py +16 -5
  14. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/adapter_fidelity/rules.py +142 -13
  15. span_panel_api-3.8.0/tests/adapter_fidelity/rules_captured.py +85 -0
  16. span_panel_api-3.8.0/tests/adapter_fidelity/test_capture_fidelity.py +153 -0
  17. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/adapter_fidelity/test_harness.py +47 -0
  18. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/adapter_fidelity/test_main32_fidelity.py +13 -4
  19. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/conftest.py +55 -1
  20. span_panel_api-3.8.0/tests/expected_failures.py +48 -0
  21. span_panel_api-3.8.0/tests/fixtures/captures/README.md +17 -0
  22. span_panel_api-3.8.0/tests/fixtures/captures/SHA256SUMS +6 -0
  23. span_panel_api-3.8.0/tests/fixtures/captures/r202639-a-tree-v1.json +1048 -0
  24. span_panel_api-3.8.0/tests/fixtures/captures/r202639-b-tree-v1.json +4039 -0
  25. span_panel_api-3.8.0/tests/fixtures/captures/r202639-c-tree-v1.json +1863 -0
  26. span_panel_api-3.8.0/tests/fixtures/captures/r202639-d-tree-v1.json +2372 -0
  27. span_panel_api-3.8.0/tests/fixtures/captures/r202639-e-tree-v1.json +10577 -0
  28. span_panel_api-3.8.0/tests/fixtures/distribution-enclosure-simulator.LICENSE +21 -0
  29. span_panel_api-3.8.0/tests/fixtures/other_model_tree.json +276 -0
  30. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/fixtures/panelbench_unvalued_by_both.json +2 -0
  31. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/reference_payloads/README.md +6 -0
  32. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/reference_payloads/__init__.py +10 -0
  33. span_panel_api-3.8.0/tests/reference_payloads/captures.py +253 -0
  34. span_panel_api-3.8.0/tests/reference_payloads/reidentify.py +207 -0
  35. span_panel_api-3.8.0/tests/reference_payloads/schema_one.py +174 -0
  36. span_panel_api-3.8.0/tests/reference_payloads/synthetic_trees.py +457 -0
  37. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_adoption.py +215 -1
  38. span_panel_api-3.8.0/tests/test_capture_replay.py +166 -0
  39. span_panel_api-3.8.0/tests/test_captures_unchanged.py +53 -0
  40. span_panel_api-3.8.0/tests/test_circuit_fed_battery_captures.py +21 -0
  41. span_panel_api-3.8.0/tests/test_declared_unvalued_captures.py +126 -0
  42. span_panel_api-3.8.0/tests/test_evse_lock_captures.py +59 -0
  43. span_panel_api-3.8.0/tests/test_evse_lock_control.py +112 -0
  44. span_panel_api-3.8.0/tests/test_expected_failures_registry.py +89 -0
  45. span_panel_api-3.8.0/tests/test_feeds_role_captures.py +30 -0
  46. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_mqtt_client_connection.py +33 -0
  47. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_mqtt_homie.py +17 -0
  48. span_panel_api-3.8.0/tests/test_panel_properties_captures.py +87 -0
  49. span_panel_api-3.8.0/tests/test_positions_captures.py +107 -0
  50. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_protocol_conformance.py +5 -0
  51. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_public_api_unchanged.py +30 -0
  52. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_publish_outcome.py +19 -0
  53. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_reference_tree_values.py +25 -14
  54. span_panel_api-3.8.0/tests/test_register_rate_limit.py +87 -0
  55. span_panel_api-3.8.0/tests/test_register_rate_limit_companions.py +92 -0
  56. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_migration_delta.py +6 -0
  57. span_panel_api-3.8.0/tests/test_schema_one_acceptance_companions.py +57 -0
  58. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_adapter.py +183 -6
  59. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_charge_limit.py +359 -28
  60. span_panel_api-3.8.0/tests/test_schema_one_circuit_fed_battery.py +61 -0
  61. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_circuits.py +1 -1
  62. span_panel_api-3.8.0/tests/test_schema_one_cleared_retained.py +253 -0
  63. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_conformance.py +37 -0
  64. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_connection_health.py +1 -1
  65. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_control_refusal.py +20 -2
  66. span_panel_api-3.8.0/tests/test_schema_one_declared_unvalued.py +125 -0
  67. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_devices.py +104 -47
  68. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_discovery.py +64 -1
  69. span_panel_api-3.8.0/tests/test_schema_one_evse_lock.py +57 -0
  70. span_panel_api-3.8.0/tests/test_schema_one_feeds_role.py +65 -0
  71. span_panel_api-3.8.0/tests/test_schema_one_other_models.py +589 -0
  72. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_panel.py +12 -10
  73. span_panel_api-3.8.0/tests/test_schema_one_panel_properties.py +168 -0
  74. span_panel_api-3.8.0/tests/test_schema_one_positions.py +125 -0
  75. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_service_entrance.py +28 -4
  76. span_panel_api-3.8.0/tests/test_schema_one_shared_meters.py +115 -0
  77. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_snapshot.py +22 -0
  78. span_panel_api-3.8.0/tests/test_schema_one_space_less_meters.py +175 -0
  79. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_transport.py +114 -0
  80. span_panel_api-3.8.0/tests/test_schema_one_tree_export.py +2201 -0
  81. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_undeclared_devices.py +1 -1
  82. span_panel_api-3.8.0/tests/test_shared_meters_captures.py +60 -0
  83. span_panel_api-3.8.0/tests/test_space_less_meters_captures.py +64 -0
  84. span_panel_api-3.8.0/tests/test_synthetic_trees.py +295 -0
  85. span_panel_api-3.8.0/tests/test_tree_export_protocol.py +23 -0
  86. span_panel_api-3.7.0b1/tests/reference_payloads/schema_one.py +0 -83
  87. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/LICENSE +0 -0
  88. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/_http.py +0 -0
  89. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/_ssl.py +0 -0
  90. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/adapters.py +0 -0
  91. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/const.py +0 -0
  92. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/detection.py +0 -0
  93. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/dispatch.py +0 -0
  94. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/factory.py +0 -0
  95. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/mqtt/__init__.py +0 -0
  96. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  97. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/mqtt/connection.py +0 -0
  98. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/mqtt/const.py +0 -0
  99. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/mqtt/models.py +0 -0
  100. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/phase_validation.py +0 -0
  101. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/py.typed +0 -0
  102. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/src/span_panel_api/schema_drift.py +0 -0
  103. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/adapter_fidelity/__init__.py +0 -0
  104. /span_panel_api-3.7.0b1/tests/fixtures/distribution-enclosure-simulator.LICENSE → /span_panel_api-3.8.0/tests/fixtures/captures/LICENSE +0 -0
  105. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  106. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  107. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  108. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/fixtures/flat_wire.json +0 -0
  109. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/fixtures/main32-tree-v1.README.md +0 -0
  110. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/fixtures/main32-tree-v1.json +0 -0
  111. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/fixtures/main32_r202639-tree-v1.README.md +0 -0
  112. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/fixtures/main32_r202639-tree-v1.json +0 -0
  113. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/fixtures/panelbench_wire.json +0 -0
  114. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/fixtures/v2/README.md +0 -0
  115. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/fixtures/v2/status.json +0 -0
  116. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/reference_payloads/bootstrap.py +0 -0
  117. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  118. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  119. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  120. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/simulation_fixtures/status.response.txt +0 -0
  121. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_absent_readings_are_not_zero.py +0 -0
  122. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_accumulator.py +0 -0
  123. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_adapters_discovery.py +0 -0
  124. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_adopted_control.py +0 -0
  125. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_async_mqtt_client.py +0 -0
  126. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_auth_and_homie_helpers.py +0 -0
  127. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_auth_redaction.py +0 -0
  128. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_ca_pinning.py +0 -0
  129. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_catalog_divergence.py +0 -0
  130. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_control_interceptor.py +0 -0
  131. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_detection_auth.py +0 -0
  132. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_exceptions.py +0 -0
  133. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_factory_dispatch.py +0 -0
  134. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_field_metadata.py +0 -0
  135. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_homie_schema_hash.py +0 -0
  136. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_https_transport.py +0 -0
  137. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_leaf_name_mismatch.py +0 -0
  138. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_live_flat_differential.py +0 -0
  139. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_mqtt_bridge.py +0 -0
  140. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_mqtt_connect_flow.py +0 -0
  141. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_mqtt_debounce.py +0 -0
  142. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_packaging.py +0 -0
  143. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_phase_validation_configs.py +0 -0
  144. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_phase_validation_errors.py +0 -0
  145. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_plaintext_warning.py +0 -0
  146. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_protocol_models.py +0 -0
  147. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_redispatch_on_reconnect.py +0 -0
  148. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_register_passphrase_unavailable.py +0 -0
  149. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_rest_transport_contract.py +0 -0
  150. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_fetch_transport_split.py +0 -0
  151. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_generation_cross_check.py +0 -0
  152. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_device_display_name.py +0 -0
  153. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_extension.py +0 -0
  154. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_firmware.py +0 -0
  155. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_pcs.py +0 -0
  156. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_shed_forecast.py +0 -0
  157. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_one_sheddable.py +0 -0
  158. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_provenance.py +0 -0
  159. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_schema_zero_adapter.py +0 -0
  160. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_shared_http_client.py +0 -0
  161. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_snapshot_readiness_gate.py +0 -0
  162. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_ssl_context.py +0 -0
  163. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/test_v2_status_parser.py +0 -0
  164. {span_panel_api-3.7.0b1 → span_panel_api-3.8.0}/tests/tls_fixtures.py +0 -0
@@ -16,6 +16,7 @@ xnotes/*
16
16
  .env
17
17
  .envrc
18
18
  .venv
19
+ .deps-installed
19
20
  .vscode/settings.json
20
21
 
21
22
  # mypy
@@ -7,32 +7,39 @@ 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
- ## [Unreleased]
10
+ ## [3.8.0]
11
11
 
12
- Snapshot fields that held a fabricated constant can now be unknown, and with `span-panel-api-schema-0` and `span-panel-api-schema-1` 1.3.0 or newer the parsers report them as unknown and stop synthesising unoccupied positions.
12
+ Snapshot fields that held a fabricated constant can now be unknown, and with `span-panel-api-schema-0` 1.3.0 and `span-panel-api-schema-1` 1.4.0 or newer the parsers report them as unknown and stop synthesising unoccupied positions.
13
13
 
14
14
  ### Added
15
15
 
16
+ - **spanos3/r202639/03 batch 2: reads more of the data this firmware publishes.**
17
+ - **`SpanMqttClient.masked_tree()` returns the panel's device tree with identifiers and site details masked**, for a consumer's diagnostics; `None` on a flat panel.
16
18
  - **`V2StatusInfo.hardware_version` reports the hardware version string from `GET /api/v2/status`.**
19
+ - **`SpanEvseSnapshot.charge_current_limit_minimum_a` and `charge_current_limit_maximum_a` report the range a SPAN Drive declares for its charge-current limit**, `None` where undeclared.
17
20
 
18
21
  ### Changed
19
22
 
20
- - **BREAKING FOR CONSUMERS: `SpanPanelSnapshot.proximity_proven` becomes `bool | None`**, and with `span-panel-api-schema-0` and `span-panel-api-schema-1` 1.3.0 or newer it is always `None`, where it was effectively a constant `True`, because neither MQTT
21
- schema publishes a proximity property and the panel's own answer is `V2StatusInfo.proximity_proven`.
23
+ - **BREAKING FOR CONSUMERS: `SpanPanelSnapshot.proximity_proven` becomes `bool | None`**, and with `span-panel-api-schema-0` 1.3.0 and `span-panel-api-schema-1` 1.4.0 or newer it is always `None`, where it was effectively a constant `True`, because neither
24
+ MQTT schema publishes a proximity property and the panel's own answer is `V2StatusInfo.proximity_proven`.
22
25
  - **BREAKING FOR CONSUMERS: `SpanPanelSnapshot.uptime_s` becomes `int | None`**, and with those parsers it is `None` on a parent/child panel and before a flat panel first reports ready, where it was a constant `0`.
23
26
  - **BREAKING FOR CONSUMERS: `SpanCircuitSnapshot.instant_power_update_time_s` and `energy_accum_update_time_s` become `int | None` and default to `None`**, and with those parsers they are `None` until the reading has arrived and always on a parent/child
24
27
  panel, where they were `0`.
25
- - **The `schema-0` and `schema-1` extras require `span-panel-api-schema-0` and `span-panel-api-schema-1` 1.3.0 or newer**, the adapters that report these values as unknown.
28
+ - **BREAKING FOR CONSUMERS: `SpanCircuitSnapshot.is_240v` becomes `bool | None`**, and with `span-panel-api-schema-1` 1.4.0 or newer it is `None` where the pole count is unpublished.
29
+ - **BREAKING FOR CONSUMERS: `SpanPanelSnapshot.lugs_at_service_entrance` becomes `bool | None`**, and with `span-panel-api-schema-1` 1.4.0 or newer it is `None` while the upstream lugs declare their feed but leave it unpublished.
30
+ - **The `schema-0` extra requires `span-panel-api-schema-0` 1.3.0 or newer and the `schema-1` extra requires `span-panel-api-schema-1` 1.4.0 or newer**, the adapters that report these values as unknown; schema-1 1.4.0 also fills the snapshot fields this
31
+ release adds.
26
32
 
27
33
  ### Removed
28
34
 
29
- - **BREAKING: with `span-panel-api-schema-0` and `span-panel-api-schema-1` 1.3.0 or newer, snapshots no longer include synthesised `unmapped_tab_N` entries for unoccupied breaker positions, which carried no measured data**, so `SpanPanelSnapshot.circuits`
30
- holds only real circuits and `panel_size` alone states the panel's total positions.
35
+ - **BREAKING: with `span-panel-api-schema-0` 1.3.0 and `span-panel-api-schema-1` 1.4.0 or newer, snapshots no longer include synthesised `unmapped_tab_N` entries for unoccupied breaker positions, which carried no measured data**, so
36
+ `SpanPanelSnapshot.circuits` holds only real circuits and `panel_size` alone states the panel's total positions.
31
37
 
32
38
  ### Fixed
33
39
 
34
40
  - **`V2HomieSchema.types_schema_hash` on data model 1.0 is the panel's published `deviceClassesSchemaHash`, or a hash of `deviceClasses` when none is published, where it was one constant for every such panel**; a flat panel's value is unchanged, and a
35
41
  value stored from 3.6.x differs once on a data model 1.0 panel.
42
+ - **A property the panel clears reads as unpublished** on a parent/child panel, where it read as an empty value until the panel published it again.
36
43
 
37
44
  ## [3.6.2]
38
45
 
@@ -1,3 +1,30 @@
1
+ Metadata-Version: 2.5
2
+ Name: span-panel-api
3
+ Version: 3.8.0
4
+ Summary: A client library for SPAN Panel API
5
+ Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
6
+ Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
7
+ Author: SpanPanel
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: Implementation :: CPython
16
+ Classifier: Topic :: Home Automation
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: <4.0,>=3.14
19
+ Requires-Dist: httpx<1.0,>=0.28.1
20
+ Requires-Dist: paho-mqtt<3.0.0,>=2.0.0
21
+ Requires-Dist: pyyaml>=6.0.0
22
+ Provides-Extra: schema-0
23
+ Requires-Dist: span-panel-api-schema-0>=1.3.0; extra == 'schema-0'
24
+ Provides-Extra: schema-1
25
+ Requires-Dist: span-panel-api-schema-1>=1.4.0; extra == 'schema-1'
26
+ Description-Content-Type: text/markdown
27
+
1
28
  # SPAN Panel API
2
29
 
3
30
  [![GitHub Release](https://img.shields.io/github/v/release/SpanPanel/span-panel-api?filter=v*&style=flat-square&label=release)](https://github.com/SpanPanel/span-panel-api/releases)
@@ -129,35 +156,42 @@ The library defines structural subtyping protocols (PEP 544). All are `runtime_c
129
156
  | `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` |
130
157
  | `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` |
131
158
  | `EvseControlProtocol` | Per-charger control: `set_evse_charge_limit(node_id, amps)` |
159
+ | `EvseLockControlProtocol` | Per-charger connector lock: `set_evse_lock(node_id, locked)` |
132
160
  | `AdoptedControlProtocol` | Write to a settable property of a device this library models nothing for |
133
161
  | `ControlInterceptionProtocol` | Install one veto-and-observe point for every control command: `set_control_interceptor` |
134
162
  | `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` |
135
163
 
136
- The first five differ in subject, not just in name. `EvseControlProtocol` is separate from `PanelControlProtocol` because several chargers may be commissioned at once and every call names which one. `AdoptedControlProtocol` differs in kind: the curated
137
- setters name a control this library understands and translate or bound the value on the way out, while this one names a property by its wire address and passes the value through, because the declaration is all anybody here knows about it. That write is
138
- authorised by the snapshot rather than by its arguments — the transport resolves the property against the current `adopted_devices` and refuses anything it does not find carrying a set topic, so a device this library _does_ model cannot be addressed
139
- through it.
164
+ The first six differ in subject, not just in name. `EvseControlProtocol` is separate from `PanelControlProtocol` because several chargers may be commissioned at once and every call names which one. `EvseLockControlProtocol` is separate from
165
+ `EvseControlProtocol` so that protocol's implementers are unchanged, and its write is authorised by the snapshot: only a charger whose `SpanEvseSnapshot.lock_control` is set can be addressed, and only with a value its `lock_state_options` lists.
166
+ `AdoptedControlProtocol` differs in kind: the curated setters name a control this library understands and translate or bound the value on the way out, while this one names a property by its wire address and passes the value through, because the declaration
167
+ is all anybody here knows about it. That write is authorised by the snapshot rather than by its arguments — the transport resolves the property against the current `adopted_devices` and refuses anything it does not find carrying a set topic, so a device
168
+ this library _does_ model cannot be addressed through it.
140
169
 
141
- `ControlInterceptionProtocol` is separate from the four control protocols rather than a member of them because adding it there would break every implementer of those protocols a second time in one release, and separate from `StreamingCapableProtocol`
142
- because a transport could reasonably offer one and not the other.
170
+ `ControlInterceptionProtocol` is separate from the control protocols rather than a member of them because adding it there would break every implementer of those protocols a second time in one release, and separate from `StreamingCapableProtocol` because a
171
+ transport could reasonably offer one and not the other.
143
172
 
144
173
  One further protocol, `SchemaAdapter`, is the bootstrap-to-parser contract rather than a consumer-facing one; it is what an adapter distribution implements and what discovery checks. Integration code programs against the protocols above, not against
145
174
  transport-specific classes.
146
175
 
176
+ `TreeExportingAdapter` sits beside `SchemaAdapter` as an optional protocol, not one every adapter must implement: an adapter that offers `export_masked_tree()` lets `SpanMqttClient.masked_tree()` return the panel's device tree with serials, device ids and
177
+ site details masked, and `None` otherwise.
178
+
147
179
  ### Snapshots
148
180
 
149
181
  All panel state is represented as immutable, frozen dataclasses:
150
182
 
151
183
  - **`SpanPanelSnapshot`**: complete panel state. Identity (serial, firmware, vendor, model, hardware version), panel size, main breaker rating, main relay, door state, connection uptime on a flat panel, network links with the Wi-Fi SSID and vendor cloud,
152
- grid and feedthrough power and energy, grid/DSM state, run configuration, dominant power source, grid islandability, per-leg voltages, power flows, lugs current and whether the lugs are the service entrance, shed policy and forecast. It holds `circuits`,
153
- `battery`, `pv`, `pv_inverters`, `evse`, `mid`, `pcs`, `adopted_devices` and `extension_properties`.
184
+ grid and feedthrough power and energy, grid/DSM state, run configuration, dominant power source, grid islandability, per-leg voltages, power flows, lugs current and whether the lugs are the service entrance, busbar current, line frequency, the rating of
185
+ the protection ahead of the upstream lugs, the first and last breaker positions, whether any circuit declares what it feeds, shed policy and forecast. It holds `circuits`, `battery`, `pv`, `pv_inverters`, `evse`, `mid`, `pcs`, `adopted_devices` and
186
+ `extension_properties`.
154
187
  - **`SpanCircuitSnapshot`**: one circuit. Id, name, relay state and requester, power, energy and their update times, tabs, 240 V, breaker rating, current, priority, user controllability, sheddable / never-backup / always-on flags, device type, relative
155
- position, PCS management and priority, and the `$target` pending state for relay and priority.
156
- - **`SpanBatterySnapshot`**: the BESS. SoE percentage and kWh, its own meter reading, communication state, link health, vendor / `model` / `part_number` / serial / version, nameplate capacity.
188
+ position, PCS management and priority, the `$target` pending state for relay and priority, nominal voltage, the breaker's protection functions, the role it declares for what it feeds, the other circuits its meter or relay is shared with, and whether it
189
+ is a meter outside the panel, which has no breaker space, relay or shed priority and reads import-positive.
190
+ - **`SpanBatterySnapshot`**: the BESS. Whether the panel declares one, SoE percentage and kWh, its own meter reading, communication state, link health, the circuit that feeds it, vendor / `model` / `part_number` / serial / version, nameplate capacity.
157
191
  - **`SpanPVSnapshot`**: one PV inverter. Link health, vendor / `model` / serial / version, nameplate capacity (the array's DC size), feeding circuit, relative position, its wire `device_id` and its `node_id`, which is its key in `pv_inverters`.
158
192
  `SpanPanelSnapshot.pv` is the lone inverter, or describes several together.
159
- - **`SpanEvseSnapshot`**: one EV charger. `node_id`, feeding circuit, status, lock state, advertised current, link health, vendor / `model` / `part_number` / serial / version, charge-current limit, ceiling, pending target and settability, and the effective
160
- limit it applies.
193
+ - **`SpanEvseSnapshot`**: one EV charger. `node_id`, feeding circuit, status, lock state, advertised current, link health, vendor / `model` / `part_number` / serial / version, charge-current limit, ceiling, pending target and settability, the effective
194
+ limit it applies, the range it declares for that limit, and where its connector lock is commanded with the values it accepts.
161
195
  - **`SpanMidSnapshot`**: the Microgrid Interconnect Device. `node_id`, islanding state, grid state, grid-forming entity and its device name, vendor / `model` / serial / software and hardware version.
162
196
  - **`SpanPcsSnapshot`**: the Power Control System. Enabled and active, the enforced import limit and its binding constraint, and each import constraint's limit, enablement and active state (feed, operator, off-grid, requested).
163
197
  - **`AdoptedDevice`**: a device type this library models nothing for, carried whole. Identity (id, type, name, vendor, model, serial, versions), its declared parent, whether a peer proxies it, and its readings.
@@ -171,6 +205,10 @@ see the changelog, because `battery.model` changes value for existing flat users
171
205
  `uptime_s` and a circuit's update times are the reverse: only the flat parser measures them, and they are `None` under the parent/child schema. Neither schema publishes proximity, so `SpanPanelSnapshot.proximity_proven` is always `None`; the panel's own
172
206
  answer is `V2StatusInfo.proximity_proven`, from `GET /api/v2/status`.
173
207
 
208
+ Two booleans can be unknown under the parent/child schema: `SpanCircuitSnapshot.is_240v` is `None` where no pole count is published, and `SpanPanelSnapshot.lugs_at_service_entrance` is `None` while the upstream lugs declare their feed and have not
209
+ published it, so a consumer tests both against `None` before reading them as a boolean. `SpanPanelSnapshot.circuits` holds only circuits the panel publishes: no entry stands in for an unoccupied breaker position, and `panel_size`, with `first_position` and
210
+ `last_position` under the parent/child schema, says which positions exist.
211
+
174
212
  ## Usage
175
213
 
176
214
  ### Factory Pattern (Recommended)
@@ -195,8 +233,11 @@ async def main():
195
233
  # The upstream lugs' own meter. That is grid flow only where the lugs are
196
234
  # the utility connection point; a BESS wired ahead of them, or a panel fed
197
235
  # by another panel, makes it this panel's feed instead. `power_flow_grid`
198
- # is the site-level figure in every topology.
199
- if snapshot.lugs_at_service_entrance:
236
+ # is the site-level figure in every topology. `None` means the lugs have
237
+ # not yet published what feeds them, so neither reading is known.
238
+ if snapshot.lugs_at_service_entrance is None:
239
+ print(f"Grid power: {snapshot.power_flow_grid}W")
240
+ elif snapshot.lugs_at_service_entrance:
200
241
  print(f"Grid power: {snapshot.instant_grid_power_w}W")
201
242
  else:
202
243
  print(f"Panel feed: {snapshot.instant_grid_power_w}W")
@@ -358,8 +399,9 @@ deliver it minutes later. `CONFIRMED` is strong evidence rather than proof: the
358
399
  idempotent in its physical effect.
359
400
 
360
401
  **A control the panel declares non-commandable raises rather than returning an outcome**, because there is no topic to publish to and so nothing to report on. `SpanPanelServerError` is raised for a relay on a circuit commissioned always-on, for the shed
361
- priority of a circuit commissioned never-backup, and for a charger with no settable charge-current limit. The same facts are on the snapshot ahead of the call — `SpanCircuitSnapshot.is_user_controllable` and `.is_never_backup` — so a consumer that offers
362
- the control only where the panel offers it will not meet this; it is the backstop for the case a setup-time gate cannot see, since re-commissioning a circuit in place changes its settability while an entity built from the earlier snapshot is still alive.
402
+ priority of a circuit commissioned never-backup, and for a charger with no settable charge-current limit or connector lock. The same facts are on the snapshot ahead of the call — `SpanCircuitSnapshot.is_user_controllable` and `.is_never_backup` — so a
403
+ consumer that offers the control only where the panel offers it will not meet this; it is the backstop for the case a setup-time gate cannot see, since re-commissioning a circuit in place changes its settability while an entity built from the earlier
404
+ snapshot is still alive.
363
405
 
364
406
  ### Control Interception
365
407
 
@@ -505,6 +547,7 @@ All exceptions inherit from `SpanPanelError`:
505
547
  | `SpanPanelValidationError` | Data validation failure |
506
548
  | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
507
549
  | `SpanPanelServerError` | Panel answered 5xx, or answered `200` with a body that cannot be used — "not ready yet" |
550
+ | `SpanPanelRateLimitError` | Panel refused a registration with HTTP 429; `retry_after_s` carries the wait it asked for |
508
551
 
509
552
  Three more are specific to the hot-loading model, and they are separate because the remedy differs:
510
553
 
@@ -584,6 +627,7 @@ One repository, three distributions. The bootstrap is at the root; each parser i
584
627
  src/span_panel_api/ # distribution: span-panel-api (no parser)
585
628
  ├── __init__.py # Public API exports
586
629
  ├── _http.py # Shared httpx plumbing / client ownership rules
630
+ ├── _ssl.py # The panel's trust anchor: SSL context, CA fingerprint, leaf-name probe
587
631
  ├── adapters.py # installed_adapter_keys(), resolve_adapter() — metadata, then lazy import
588
632
  ├── auth.py # v2 HTTP provisioning (register, cert, schema, passphrase)
589
633
  ├── const.py # Panel state constants (DSM, relay)
@@ -601,6 +645,7 @@ src/span_panel_api/ # distribution: span-panel-api (no parser)
601
645
  ├── client.py # SpanMqttClient (transport + control protocols)
602
646
  ├── connection.py # AsyncMqttBridge (event-loop-driven, no threads)
603
647
  ├── const.py # MQTT/Homie constants + UUID helpers
648
+ ├── control.py # PublishOutcome, ControlCommand, ControlInterceptor
604
649
  └── models.py # MqttClientConfig, MqttTransport
605
650
 
606
651
  packages/schema-0/ # distribution: span-panel-api-schema-0
@@ -1,30 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: span-panel-api
3
- Version: 3.7.0b1
4
- Summary: A client library for SPAN Panel API
5
- Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
6
- Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
7
- Author: SpanPanel
8
- License-Expression: MIT
9
- License-File: LICENSE
10
- Classifier: Development Status :: 5 - Production/Stable
11
- Classifier: Intended Audience :: Developers
12
- Classifier: License :: OSI Approved :: MIT License
13
- Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.14
15
- Classifier: Programming Language :: Python :: Implementation :: CPython
16
- Classifier: Topic :: Home Automation
17
- Classifier: Typing :: Typed
18
- Requires-Python: <4.0,>=3.14
19
- Requires-Dist: httpx<1.0,>=0.28.1
20
- Requires-Dist: paho-mqtt<3.0.0,>=2.0.0
21
- Requires-Dist: pyyaml>=6.0.0
22
- Provides-Extra: schema-0
23
- Requires-Dist: span-panel-api-schema-0>=1.3.0b1; extra == 'schema-0'
24
- Provides-Extra: schema-1
25
- Requires-Dist: span-panel-api-schema-1>=1.3.0b1; extra == 'schema-1'
26
- Description-Content-Type: text/markdown
27
-
28
1
  # SPAN Panel API
29
2
 
30
3
  [![GitHub Release](https://img.shields.io/github/v/release/SpanPanel/span-panel-api?filter=v*&style=flat-square&label=release)](https://github.com/SpanPanel/span-panel-api/releases)
@@ -156,35 +129,42 @@ The library defines structural subtyping protocols (PEP 544). All are `runtime_c
156
129
  | `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` |
157
130
  | `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` |
158
131
  | `EvseControlProtocol` | Per-charger control: `set_evse_charge_limit(node_id, amps)` |
132
+ | `EvseLockControlProtocol` | Per-charger connector lock: `set_evse_lock(node_id, locked)` |
159
133
  | `AdoptedControlProtocol` | Write to a settable property of a device this library models nothing for |
160
134
  | `ControlInterceptionProtocol` | Install one veto-and-observe point for every control command: `set_control_interceptor` |
161
135
  | `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` |
162
136
 
163
- The first five differ in subject, not just in name. `EvseControlProtocol` is separate from `PanelControlProtocol` because several chargers may be commissioned at once and every call names which one. `AdoptedControlProtocol` differs in kind: the curated
164
- setters name a control this library understands and translate or bound the value on the way out, while this one names a property by its wire address and passes the value through, because the declaration is all anybody here knows about it. That write is
165
- authorised by the snapshot rather than by its arguments — the transport resolves the property against the current `adopted_devices` and refuses anything it does not find carrying a set topic, so a device this library _does_ model cannot be addressed
166
- through it.
137
+ The first six differ in subject, not just in name. `EvseControlProtocol` is separate from `PanelControlProtocol` because several chargers may be commissioned at once and every call names which one. `EvseLockControlProtocol` is separate from
138
+ `EvseControlProtocol` so that protocol's implementers are unchanged, and its write is authorised by the snapshot: only a charger whose `SpanEvseSnapshot.lock_control` is set can be addressed, and only with a value its `lock_state_options` lists.
139
+ `AdoptedControlProtocol` differs in kind: the curated setters name a control this library understands and translate or bound the value on the way out, while this one names a property by its wire address and passes the value through, because the declaration
140
+ is all anybody here knows about it. That write is authorised by the snapshot rather than by its arguments — the transport resolves the property against the current `adopted_devices` and refuses anything it does not find carrying a set topic, so a device
141
+ this library _does_ model cannot be addressed through it.
167
142
 
168
- `ControlInterceptionProtocol` is separate from the four control protocols rather than a member of them because adding it there would break every implementer of those protocols a second time in one release, and separate from `StreamingCapableProtocol`
169
- because a transport could reasonably offer one and not the other.
143
+ `ControlInterceptionProtocol` is separate from the control protocols rather than a member of them because adding it there would break every implementer of those protocols a second time in one release, and separate from `StreamingCapableProtocol` because a
144
+ transport could reasonably offer one and not the other.
170
145
 
171
146
  One further protocol, `SchemaAdapter`, is the bootstrap-to-parser contract rather than a consumer-facing one; it is what an adapter distribution implements and what discovery checks. Integration code programs against the protocols above, not against
172
147
  transport-specific classes.
173
148
 
149
+ `TreeExportingAdapter` sits beside `SchemaAdapter` as an optional protocol, not one every adapter must implement: an adapter that offers `export_masked_tree()` lets `SpanMqttClient.masked_tree()` return the panel's device tree with serials, device ids and
150
+ site details masked, and `None` otherwise.
151
+
174
152
  ### Snapshots
175
153
 
176
154
  All panel state is represented as immutable, frozen dataclasses:
177
155
 
178
156
  - **`SpanPanelSnapshot`**: complete panel state. Identity (serial, firmware, vendor, model, hardware version), panel size, main breaker rating, main relay, door state, connection uptime on a flat panel, network links with the Wi-Fi SSID and vendor cloud,
179
- grid and feedthrough power and energy, grid/DSM state, run configuration, dominant power source, grid islandability, per-leg voltages, power flows, lugs current and whether the lugs are the service entrance, shed policy and forecast. It holds `circuits`,
180
- `battery`, `pv`, `pv_inverters`, `evse`, `mid`, `pcs`, `adopted_devices` and `extension_properties`.
157
+ grid and feedthrough power and energy, grid/DSM state, run configuration, dominant power source, grid islandability, per-leg voltages, power flows, lugs current and whether the lugs are the service entrance, busbar current, line frequency, the rating of
158
+ the protection ahead of the upstream lugs, the first and last breaker positions, whether any circuit declares what it feeds, shed policy and forecast. It holds `circuits`, `battery`, `pv`, `pv_inverters`, `evse`, `mid`, `pcs`, `adopted_devices` and
159
+ `extension_properties`.
181
160
  - **`SpanCircuitSnapshot`**: one circuit. Id, name, relay state and requester, power, energy and their update times, tabs, 240 V, breaker rating, current, priority, user controllability, sheddable / never-backup / always-on flags, device type, relative
182
- position, PCS management and priority, and the `$target` pending state for relay and priority.
183
- - **`SpanBatterySnapshot`**: the BESS. SoE percentage and kWh, its own meter reading, communication state, link health, vendor / `model` / `part_number` / serial / version, nameplate capacity.
161
+ position, PCS management and priority, the `$target` pending state for relay and priority, nominal voltage, the breaker's protection functions, the role it declares for what it feeds, the other circuits its meter or relay is shared with, and whether it
162
+ is a meter outside the panel, which has no breaker space, relay or shed priority and reads import-positive.
163
+ - **`SpanBatterySnapshot`**: the BESS. Whether the panel declares one, SoE percentage and kWh, its own meter reading, communication state, link health, the circuit that feeds it, vendor / `model` / `part_number` / serial / version, nameplate capacity.
184
164
  - **`SpanPVSnapshot`**: one PV inverter. Link health, vendor / `model` / serial / version, nameplate capacity (the array's DC size), feeding circuit, relative position, its wire `device_id` and its `node_id`, which is its key in `pv_inverters`.
185
165
  `SpanPanelSnapshot.pv` is the lone inverter, or describes several together.
186
- - **`SpanEvseSnapshot`**: one EV charger. `node_id`, feeding circuit, status, lock state, advertised current, link health, vendor / `model` / `part_number` / serial / version, charge-current limit, ceiling, pending target and settability, and the effective
187
- limit it applies.
166
+ - **`SpanEvseSnapshot`**: one EV charger. `node_id`, feeding circuit, status, lock state, advertised current, link health, vendor / `model` / `part_number` / serial / version, charge-current limit, ceiling, pending target and settability, the effective
167
+ limit it applies, the range it declares for that limit, and where its connector lock is commanded with the values it accepts.
188
168
  - **`SpanMidSnapshot`**: the Microgrid Interconnect Device. `node_id`, islanding state, grid state, grid-forming entity and its device name, vendor / `model` / serial / software and hardware version.
189
169
  - **`SpanPcsSnapshot`**: the Power Control System. Enabled and active, the enforced import limit and its binding constraint, and each import constraint's limit, enablement and active state (feed, operator, off-grid, requested).
190
170
  - **`AdoptedDevice`**: a device type this library models nothing for, carried whole. Identity (id, type, name, vendor, model, serial, versions), its declared parent, whether a peer proxies it, and its readings.
@@ -198,6 +178,10 @@ see the changelog, because `battery.model` changes value for existing flat users
198
178
  `uptime_s` and a circuit's update times are the reverse: only the flat parser measures them, and they are `None` under the parent/child schema. Neither schema publishes proximity, so `SpanPanelSnapshot.proximity_proven` is always `None`; the panel's own
199
179
  answer is `V2StatusInfo.proximity_proven`, from `GET /api/v2/status`.
200
180
 
181
+ Two booleans can be unknown under the parent/child schema: `SpanCircuitSnapshot.is_240v` is `None` where no pole count is published, and `SpanPanelSnapshot.lugs_at_service_entrance` is `None` while the upstream lugs declare their feed and have not
182
+ published it, so a consumer tests both against `None` before reading them as a boolean. `SpanPanelSnapshot.circuits` holds only circuits the panel publishes: no entry stands in for an unoccupied breaker position, and `panel_size`, with `first_position` and
183
+ `last_position` under the parent/child schema, says which positions exist.
184
+
201
185
  ## Usage
202
186
 
203
187
  ### Factory Pattern (Recommended)
@@ -222,8 +206,11 @@ async def main():
222
206
  # The upstream lugs' own meter. That is grid flow only where the lugs are
223
207
  # the utility connection point; a BESS wired ahead of them, or a panel fed
224
208
  # by another panel, makes it this panel's feed instead. `power_flow_grid`
225
- # is the site-level figure in every topology.
226
- if snapshot.lugs_at_service_entrance:
209
+ # is the site-level figure in every topology. `None` means the lugs have
210
+ # not yet published what feeds them, so neither reading is known.
211
+ if snapshot.lugs_at_service_entrance is None:
212
+ print(f"Grid power: {snapshot.power_flow_grid}W")
213
+ elif snapshot.lugs_at_service_entrance:
227
214
  print(f"Grid power: {snapshot.instant_grid_power_w}W")
228
215
  else:
229
216
  print(f"Panel feed: {snapshot.instant_grid_power_w}W")
@@ -385,8 +372,9 @@ deliver it minutes later. `CONFIRMED` is strong evidence rather than proof: the
385
372
  idempotent in its physical effect.
386
373
 
387
374
  **A control the panel declares non-commandable raises rather than returning an outcome**, because there is no topic to publish to and so nothing to report on. `SpanPanelServerError` is raised for a relay on a circuit commissioned always-on, for the shed
388
- priority of a circuit commissioned never-backup, and for a charger with no settable charge-current limit. The same facts are on the snapshot ahead of the call — `SpanCircuitSnapshot.is_user_controllable` and `.is_never_backup` — so a consumer that offers
389
- the control only where the panel offers it will not meet this; it is the backstop for the case a setup-time gate cannot see, since re-commissioning a circuit in place changes its settability while an entity built from the earlier snapshot is still alive.
375
+ priority of a circuit commissioned never-backup, and for a charger with no settable charge-current limit or connector lock. The same facts are on the snapshot ahead of the call — `SpanCircuitSnapshot.is_user_controllable` and `.is_never_backup` — so a
376
+ consumer that offers the control only where the panel offers it will not meet this; it is the backstop for the case a setup-time gate cannot see, since re-commissioning a circuit in place changes its settability while an entity built from the earlier
377
+ snapshot is still alive.
390
378
 
391
379
  ### Control Interception
392
380
 
@@ -532,6 +520,7 @@ All exceptions inherit from `SpanPanelError`:
532
520
  | `SpanPanelValidationError` | Data validation failure |
533
521
  | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
534
522
  | `SpanPanelServerError` | Panel answered 5xx, or answered `200` with a body that cannot be used — "not ready yet" |
523
+ | `SpanPanelRateLimitError` | Panel refused a registration with HTTP 429; `retry_after_s` carries the wait it asked for |
535
524
 
536
525
  Three more are specific to the hot-loading model, and they are separate because the remedy differs:
537
526
 
@@ -611,6 +600,7 @@ One repository, three distributions. The bootstrap is at the root; each parser i
611
600
  src/span_panel_api/ # distribution: span-panel-api (no parser)
612
601
  ├── __init__.py # Public API exports
613
602
  ├── _http.py # Shared httpx plumbing / client ownership rules
603
+ ├── _ssl.py # The panel's trust anchor: SSL context, CA fingerprint, leaf-name probe
614
604
  ├── adapters.py # installed_adapter_keys(), resolve_adapter() — metadata, then lazy import
615
605
  ├── auth.py # v2 HTTP provisioning (register, cert, schema, passphrase)
616
606
  ├── const.py # Panel state constants (DSM, relay)
@@ -628,6 +618,7 @@ src/span_panel_api/ # distribution: span-panel-api (no parser)
628
618
  ├── client.py # SpanMqttClient (transport + control protocols)
629
619
  ├── connection.py # AsyncMqttBridge (event-loop-driven, no threads)
630
620
  ├── const.py # MQTT/Homie constants + UUID helpers
621
+ ├── control.py # PublishOutcome, ControlCommand, ControlInterceptor
631
622
  └── models.py # MqttClientConfig, MqttTransport
632
623
 
633
624
  packages/schema-0/ # distribution: span-panel-api-schema-0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "3.7.0b1"
3
+ version = "3.8.0"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -62,14 +62,16 @@ dependencies = [
62
62
  # so a 1.1.x adapter is still accepted, but it fills neither `pv_inverters` nor
63
63
  # the inverter identity fields 3.6.0 adds. Upgrading through the extra is what
64
64
  # brings the adapters that do.
65
- # Raised to 1.3.0b1 for 3.7.0b1: that adapter reports the unknown values this
65
+ # Raised to 1.3.0 for 3.8.0: that adapter reports the unknown values this
66
66
  # bootstrap's snapshot fields now accept, and stops synthesising unoccupied positions.
67
- schema-0 = ["span-panel-api-schema-0>=1.3.0b1"]
67
+ schema-0 = ["span-panel-api-schema-0>=1.3.0"]
68
68
  # Raised to 1.2.1 for 3.6.1: that adapter builds the `pv` that `SpanPanelSnapshot.pv` now documents.
69
69
  # Raised to 1.2.2 for 3.6.2: the readiness gate every snapshot passes asks that
70
70
  # adapter what is still missing, and 1.2.2 restarts its feed grace correctly.
71
- # Raised to 1.3.0b1 for 3.7.0b1, for the same reason as schema-0's.
72
- schema-1 = ["span-panel-api-schema-1>=1.3.0b1"]
71
+ # Raised to 1.4.0 for 3.8.0: for the same reason as schema-0's, and because that
72
+ # adapter fills the snapshot fields this bootstrap adds and exports the masked
73
+ # device tree.
74
+ schema-1 = ["span-panel-api-schema-1>=1.4.0"]
73
75
 
74
76
  [project.urls]
75
77
  Homepage = "https://github.com/SpanPanel/span-panel-api"
@@ -93,7 +95,7 @@ dev = [
93
95
  # known. Dependabot raises the bump on its own; the bump PR re-runs
94
96
  # `scripts/capture_parent_child_reference.py` and the suite then says whether
95
97
  # the wire moved.
96
- "ebus-panel-sim==0.8.0",
98
+ "ebus-panel-sim==0.9.0",
97
99
  "pytest>=9.0.2",
98
100
  "pytest-asyncio>=1.3.0",
99
101
  "pytest-cov",
@@ -253,6 +255,12 @@ explicit_package_bases = true
253
255
  # `tests/` on `sys.path`; the support packages the mypy hook checks resolve the same
254
256
  # way here.
255
257
  mypy_path = "src:tests"
258
+ # paho ships `py.typed` but leaves its topic matcher unannotated, so every call
259
+ # into it would be an untyped call. The schema-1 route table resolves what the
260
+ # matcher yields through a typed dict at once, which keeps its `Any` from going
261
+ # further; `warn_return_any` does not police that for you (a union or a
262
+ # `yield from` passes it), so keep it to that shape.
263
+ untyped_calls_exclude = ["paho.mqtt.matcher"]
256
264
  # Excluded by name for the reason `[tool.ruff]` gives above: the capture script is
257
265
  # checked like the library, and the hand-run tools written before that was expected
258
266
  # are not.
@@ -29,6 +29,7 @@ from .exceptions import (
29
29
  SpanPanelError,
30
30
  SpanPanelInsufficientPrivilegeError,
31
31
  SpanPanelPassphraseUnavailableError,
32
+ SpanPanelRateLimitError,
32
33
  SpanPanelSchemaVersionError,
33
34
  SpanPanelServerError,
34
35
  SpanPanelStaleDataError,
@@ -41,15 +42,22 @@ from .models import (
41
42
  ADOPTION_IDENTITY_NODE,
42
43
  ADOPTION_TOPOLOGY_NODE,
43
44
  DISCOVERY_NAMESPACE,
45
+ FEEDS_ROLES,
46
+ PROTECTION_FUNCTIONS,
44
47
  AdoptedDevice,
45
48
  AdoptedProperty,
46
49
  ControlTarget,
47
50
  DiscoveredMetadata,
48
51
  ExtensionProperty,
49
52
  ExtensionSubject,
53
+ FeedsRole,
50
54
  FieldMetadata,
51
55
  HomieSchemaTypes,
56
+ MaskedTree,
57
+ MaskedTreeDevice,
58
+ MaskedTreeMetadata,
52
59
  PassphraseRotation,
60
+ ProtectionFunction,
53
61
  SpanBatterySnapshot,
54
62
  SpanCircuitSnapshot,
55
63
  SpanEvseSnapshot,
@@ -57,10 +65,12 @@ from .models import (
57
65
  SpanPanelSnapshot,
58
66
  SpanPcsSnapshot,
59
67
  SpanPVSnapshot,
68
+ UndescribedDevice,
60
69
  V2AuthResponse,
61
70
  V2HomieSchema,
62
71
  V2StatusInfo,
63
72
  is_discovery_path,
73
+ shared_meter_groups,
64
74
  )
65
75
  from .mqtt import (
66
76
  ControlCommand,
@@ -84,10 +94,12 @@ from .protocol import (
84
94
  CircuitControlProtocol,
85
95
  ControlInterceptionProtocol,
86
96
  EvseControlProtocol,
97
+ EvseLockControlProtocol,
87
98
  PanelCapability,
88
99
  PanelControlProtocol,
89
100
  SpanPanelClientProtocol,
90
101
  StreamingCapableProtocol,
102
+ TreeExportingAdapter,
91
103
  )
92
104
 
93
105
  __version__ = _pkg_version("span-panel-api")
@@ -106,6 +118,9 @@ __all__ = [ # noqa: RUF022
106
118
  # snapshot rather than by its arguments -- a device the adapter models
107
119
  # produces no AdoptedDevice and so cannot be addressed through it.
108
120
  "AdoptedControlProtocol",
121
+ # A charger's connector lock, authorised by `SpanEvseSnapshot.lock_control`.
122
+ # A protocol of its own so `EvseControlProtocol`'s implementers are unchanged.
123
+ "EvseLockControlProtocol",
109
124
  # Added 2026-08-25 (3.1.0): one veto/observe point for every control
110
125
  # command. A protocol of its own rather than a member on the four control
111
126
  # protocols, which would break their implementers a second time in one
@@ -137,6 +152,15 @@ __all__ = [ # noqa: RUF022
137
152
  "ControlTarget",
138
153
  "ExtensionProperty",
139
154
  "ExtensionSubject",
155
+ # Added 2026-10-31: the masked device tree a consumer forwards in diagnostics,
156
+ # and the optional protocol an adapter offers it through. Deliberately not a
157
+ # SchemaAdapter member: the protocol derives its required set from itself, so a
158
+ # member there would invalidate every built adapter wheel.
159
+ "MaskedTree",
160
+ "MaskedTreeDevice",
161
+ "MaskedTreeMetadata",
162
+ "UndescribedDevice",
163
+ "TreeExportingAdapter",
140
164
  # Snapshots
141
165
  "SpanBatterySnapshot",
142
166
  "SpanCircuitSnapshot",
@@ -145,6 +169,16 @@ __all__ = [ # noqa: RUF022
145
169
  "SpanPVSnapshot",
146
170
  "SpanPanelSnapshot",
147
171
  "SpanPcsSnapshot",
172
+ # What a circuit's `connection/feeds-role` declares, read into
173
+ # `SpanCircuitSnapshot.feeds_role`.
174
+ "FEEDS_ROLES",
175
+ "FeedsRole",
176
+ # What a circuit's `breaker/protection-functions` lists, read into
177
+ # `SpanCircuitSnapshot.protection_functions`.
178
+ "PROTECTION_FUNCTIONS",
179
+ "ProtectionFunction",
180
+ # The circuits one meter measures, from `SpanCircuitSnapshot.meter_shared_with`.
181
+ "shared_meter_groups",
148
182
  # Factory
149
183
  "create_span_client",
150
184
  # Detection
@@ -216,6 +250,10 @@ __all__ = [ # noqa: RUF022
216
250
  # keep their meaning; deliberately not a SpanPanelAuthError, because the
217
251
  # passphrase the user supplied may be correct.
218
252
  "SpanPanelPassphraseUnavailableError",
253
+ # Registration refused with HTTP 429, carrying the panel's `Retry-After` in
254
+ # seconds. A subclass of SpanPanelAPIError, so existing except clauses keep
255
+ # their meaning; not a SpanPanelAuthError, because no credential was judged.
256
+ "SpanPanelRateLimitError",
219
257
  "SpanPanelServerError",
220
258
  "SpanPanelStaleDataError",
221
259
  # Added 2026-08-31 (3.4.0): a bootstrap REST call that failed verification
@@ -10,9 +10,12 @@ from __future__ import annotations
10
10
 
11
11
  import asyncio
12
12
  from collections.abc import Collection, Mapping
13
+ from datetime import UTC, datetime
14
+ from email.utils import parsedate_to_datetime
13
15
  import hashlib
14
16
  import json
15
17
  import logging
18
+ import math
16
19
  import ssl
17
20
  import uuid
18
21
 
@@ -24,6 +27,7 @@ from .exceptions import (
24
27
  SpanPanelAuthError,
25
28
  SpanPanelInsufficientPrivilegeError,
26
29
  SpanPanelPassphraseUnavailableError,
30
+ SpanPanelRateLimitError,
27
31
  SpanPanelServerError,
28
32
  )
29
33
  from .models import HomieSchemaTypes, PassphraseRotation, V2AuthResponse, V2HomieSchema, V2StatusInfo
@@ -216,21 +220,37 @@ CA_CERT_MAX_ATTEMPTS = 5
216
220
  CA_CERT_BACKOFF_S = 1.5
217
221
 
218
222
 
219
- def _retry_delay(retry_after: str | None, attempt: int, backoff_s: float) -> float:
220
- """Return how long to wait before retrying a rate-limited request.
223
+ def _retry_after_s(retry_after: str | None, now: datetime) -> float | None:
224
+ """The wait a ``Retry-After`` header asks for, in seconds, or None if it asks for none.
221
225
 
222
- Prefers the panel's ``Retry-After`` header (delta-seconds form) and falls
223
- back to exponential backoff. Malformed or negative values fall back too,
224
- so a bad header can never stall or skip the retry.
226
+ Both forms RFC 9110 allows: delta-seconds, taken as given, and an HTTP-date,
227
+ taken as the seconds from `now` until then (0.0 once it has passed). A
228
+ negative, infinite or unparseable value is no answer, so a bad header can
229
+ never stall or skip a retry.
225
230
  """
226
- fallback = backoff_s * (2.0 ** (attempt - 1))
227
231
  if retry_after is None:
228
- return fallback
232
+ return None
229
233
  try:
230
- parsed = float(retry_after)
231
- except (TypeError, ValueError):
232
- return fallback
233
- return parsed if parsed >= 0 else fallback
234
+ seconds = float(retry_after)
235
+ except ValueError:
236
+ try:
237
+ until = parsedate_to_datetime(retry_after)
238
+ except (TypeError, ValueError):
239
+ return None
240
+ if until.tzinfo is None:
241
+ return None
242
+ return max((until - now).total_seconds(), 0.0)
243
+ return seconds if math.isfinite(seconds) and seconds >= 0 else None
244
+
245
+
246
+ def _retry_delay(retry_after: str | None, attempt: int, backoff_s: float) -> float:
247
+ """Return how long to wait before retrying a rate-limited request.
248
+
249
+ Prefers the panel's ``Retry-After`` header and falls back to exponential
250
+ backoff where it gives no usable wait.
251
+ """
252
+ parsed = _retry_after_s(retry_after, datetime.now(UTC))
253
+ return backoff_s * (2.0 ** (attempt - 1)) if parsed is None else parsed
234
254
 
235
255
 
236
256
  async def register_v2(
@@ -276,6 +296,7 @@ async def register_v2(
276
296
  Raises:
277
297
  SpanPanelAuthError: Invalid passphrase or auth failure
278
298
  SpanPanelPassphraseUnavailableError: The panel cannot read its own passphrase
299
+ SpanPanelRateLimitError: HTTP 429; the panel is limiting registrations. Not retried
279
300
  SpanPanelServerError: The panel is not ready to register clients (any 5xx,
280
301
  including the 503 it answers before its serial number is known); retryable
281
302
  SpanPanelConnectionError: Cannot reach panel
@@ -314,6 +335,16 @@ async def register_v2(
314
335
  status_code=reply.status_code,
315
336
  )
316
337
 
338
+ if reply.status_code == HTTP_TOO_MANY_REQUESTS:
339
+ # Not retried: the panel limits registrations per client address, and a
340
+ # retry here would spend the caller's next attempt on its behalf.
341
+ _log_auth_failure(reply.endpoint, reply.response, sent)
342
+ raise SpanPanelRateLimitError(
343
+ f"Panel is rate-limiting registration (HTTP {reply.status_code})",
344
+ status_code=reply.status_code,
345
+ retry_after_s=_retry_after_s(reply.headers.get("retry-after"), datetime.now(UTC)),
346
+ )
347
+
317
348
  if reply.status_code == 422 and _error_detail(reply.response) == REGISTRATION_UNAVAILABLE_DETAIL:
318
349
  # Checked before the general 422 below, which means "credential not
319
350
  # accepted". This one is the panel failing to read its own passphrase,
@@ -613,8 +644,8 @@ async def rotate_passphrase(
613
644
  Raises:
614
645
  SpanPanelInsufficientPrivilegeError: HTTP 403; the token is reduced-privilege
615
646
  SpanPanelAuthError: HTTP 401 (token invalid or stale) or 412 (no bearer token)
616
- SpanPanelServerError: HTTP 5xx. After a 500 the outcome is unknown: the
617
- passphrase may or may not have changed.
647
+ SpanPanelServerError: HTTP 5xx. After a 500 or a 504 the outcome is unknown:
648
+ the passphrase may or may not have changed.
618
649
  SpanPanelConnectionError: Cannot reach panel
619
650
  SpanPanelTimeoutError: Request timed out
620
651
  SpanPanelAPIError: Unexpected response