span-panel-api 3.6.2__tar.gz → 3.7.0b1__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 (120) hide show
  1. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/CHANGELOG.md +28 -2
  2. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/PKG-INFO +8 -5
  3. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/README.md +5 -2
  4. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/pyproject.toml +11 -4
  5. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/auth.py +30 -10
  6. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/models.py +32 -9
  7. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/mqtt/connection.py +1 -2
  8. span_panel_api-3.7.0b1/tests/adapter_fidelity/__init__.py +5 -0
  9. span_panel_api-3.7.0b1/tests/adapter_fidelity/harness.py +858 -0
  10. span_panel_api-3.7.0b1/tests/adapter_fidelity/rules.py +407 -0
  11. span_panel_api-3.7.0b1/tests/adapter_fidelity/test_harness.py +194 -0
  12. span_panel_api-3.7.0b1/tests/adapter_fidelity/test_main32_fidelity.py +172 -0
  13. span_panel_api-3.7.0b1/tests/fixtures/distribution-enclosure-simulator.LICENSE +21 -0
  14. span_panel_api-3.7.0b1/tests/fixtures/main32-tree-v1.README.md +9 -0
  15. span_panel_api-3.7.0b1/tests/fixtures/main32-tree-v1.json +3748 -0
  16. span_panel_api-3.7.0b1/tests/fixtures/main32_r202639-tree-v1.README.md +8 -0
  17. span_panel_api-3.7.0b1/tests/fixtures/main32_r202639-tree-v1.json +3292 -0
  18. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_absent_readings_are_not_zero.py +0 -15
  19. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_accumulator.py +3 -2
  20. span_panel_api-3.7.0b1/tests/test_homie_schema_hash.py +87 -0
  21. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_mqtt_client_connection.py +1 -2
  22. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_mqtt_homie.py +65 -196
  23. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_register_passphrase_unavailable.py +5 -2
  24. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_migration_delta.py +28 -7
  25. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_adapter.py +4 -4
  26. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_circuits.py +6 -6
  27. span_panel_api-3.7.0b1/tests/test_schema_one_device_display_name.py +91 -0
  28. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_panel.py +2 -40
  29. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_pcs.py +0 -10
  30. span_panel_api-3.7.0b1/tests/test_schema_one_sheddable.py +46 -0
  31. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_snapshot.py +21 -22
  32. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_v2_status_parser.py +36 -0
  33. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/.gitignore +0 -0
  34. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/LICENSE +0 -0
  35. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/__init__.py +0 -0
  36. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/_http.py +0 -0
  37. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/_ssl.py +0 -0
  38. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/adapters.py +0 -0
  39. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/const.py +0 -0
  40. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/detection.py +0 -0
  41. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/dispatch.py +0 -0
  42. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/exceptions.py +0 -0
  43. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/factory.py +0 -0
  44. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/mqtt/__init__.py +0 -0
  45. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/mqtt/async_client.py +0 -0
  46. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/mqtt/client.py +0 -0
  47. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/mqtt/const.py +0 -0
  48. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/mqtt/control.py +0 -0
  49. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/mqtt/models.py +0 -0
  50. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/phase_validation.py +0 -0
  51. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/protocol.py +0 -0
  52. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/py.typed +0 -0
  53. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/src/span_panel_api/schema_drift.py +0 -0
  54. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/conftest.py +0 -0
  55. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  56. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  57. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  58. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/fixtures/flat_wire.json +0 -0
  59. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  60. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/fixtures/panelbench_wire.json +0 -0
  61. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/fixtures/v2/README.md +0 -0
  62. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/fixtures/v2/status.json +0 -0
  63. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/reference_payloads/README.md +0 -0
  64. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/reference_payloads/__init__.py +0 -0
  65. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/reference_payloads/bootstrap.py +0 -0
  66. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/reference_payloads/schema_one.py +0 -0
  67. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/simulation_fixtures/circuits.response.txt +0 -0
  68. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/simulation_fixtures/panel.response.txt +0 -0
  69. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/simulation_fixtures/soe.response.txt +0 -0
  70. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/simulation_fixtures/status.response.txt +0 -0
  71. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_adapters_discovery.py +0 -0
  72. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_adopted_control.py +0 -0
  73. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_adoption.py +0 -0
  74. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_async_mqtt_client.py +0 -0
  75. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_auth_and_homie_helpers.py +0 -0
  76. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_auth_redaction.py +0 -0
  77. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_ca_pinning.py +0 -0
  78. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_catalog_divergence.py +0 -0
  79. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_control_interceptor.py +0 -0
  80. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_detection_auth.py +0 -0
  81. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_exceptions.py +0 -0
  82. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_factory_dispatch.py +0 -0
  83. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_field_metadata.py +0 -0
  84. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_https_transport.py +0 -0
  85. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_leaf_name_mismatch.py +0 -0
  86. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_live_flat_differential.py +0 -0
  87. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_mqtt_bridge.py +0 -0
  88. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_mqtt_connect_flow.py +0 -0
  89. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_mqtt_debounce.py +0 -0
  90. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_packaging.py +0 -0
  91. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_phase_validation_configs.py +0 -0
  92. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_phase_validation_errors.py +0 -0
  93. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_plaintext_warning.py +0 -0
  94. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_protocol_conformance.py +0 -0
  95. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_protocol_models.py +0 -0
  96. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_public_api_unchanged.py +0 -0
  97. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_publish_outcome.py +0 -0
  98. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_redispatch_on_reconnect.py +0 -0
  99. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_reference_tree_values.py +0 -0
  100. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_rest_transport_contract.py +0 -0
  101. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_fetch_transport_split.py +0 -0
  102. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_generation_cross_check.py +0 -0
  103. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_charge_limit.py +0 -0
  104. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_conformance.py +0 -0
  105. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_connection_health.py +0 -0
  106. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_control_refusal.py +0 -0
  107. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_devices.py +0 -0
  108. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_discovery.py +0 -0
  109. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_extension.py +0 -0
  110. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_firmware.py +0 -0
  111. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_service_entrance.py +0 -0
  112. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_shed_forecast.py +0 -0
  113. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_transport.py +0 -0
  114. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_one_undeclared_devices.py +0 -0
  115. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_provenance.py +0 -0
  116. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_schema_zero_adapter.py +0 -0
  117. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_shared_http_client.py +0 -0
  118. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_snapshot_readiness_gate.py +0 -0
  119. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/test_ssl_context.py +0 -0
  120. {span_panel_api-3.6.2 → span_panel_api-3.7.0b1}/tests/tls_fixtures.py +0 -0
@@ -7,6 +7,33 @@ 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]
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.
13
+
14
+ ### Added
15
+
16
+ - **`V2StatusInfo.hardware_version` reports the hardware version string from `GET /api/v2/status`.**
17
+
18
+ ### Changed
19
+
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`.
22
+ - **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
+ - **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
+ 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.
26
+
27
+ ### Removed
28
+
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.
31
+
32
+ ### Fixed
33
+
34
+ - **`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
+ value stored from 3.6.x differs once on a data model 1.0 panel.
36
+
10
37
  ## [3.6.2]
11
38
 
12
39
  A reconnect waits for the panel's labels before handing out a snapshot, as connecting always has.
@@ -480,8 +507,7 @@ Everything below is additive. Each field is `None` or empty on a panel that publ
480
507
  `MQTT_FULL_REBUILD_AFTER_FAILURES` (3) consecutive failures, or immediately on any `ssl.SSLError`. The previous behavior pinned the panel's CA certificate into the paho client once at `connect()` time and re-used it across all reconnect attempts; if the
481
508
  panel rotated its private CA — most plausibly during a firmware upgrade — every subsequent reconnect raised `ssl.SSLCertVerificationError` (caught by the broad `OSError` clause and silently retried) and the bridge could not recover without a config-entry
482
509
  reload. The rebuild mirrors what a manual reload does without going through HA's `config_entry` teardown, so entities stay registered and the integration's grace-period logic continues to apply unchanged. The threshold-cadence design (counter reset on
483
- every rebuild attempt, success or fail) keeps the recovery path active throughout extended outages — multi-day disconnections recover whenever the panel becomes usable again, including if the CA rotates a second time mid-outage. See
484
- `SpanPanel_Docs/span-panel-api/2026-05-17-mqtt-ca-refresh-on-reconnect-design.md` for the full design.
510
+ every rebuild attempt, success or fail) keeps the recovery path active throughout extended outages — multi-day disconnections recover whenever the panel becomes usable again, including if the CA rotates a second time mid-outage.
485
511
 
486
512
  ### Added
487
513
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 3.6.2
3
+ Version: 3.7.0b1
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
@@ -20,9 +20,9 @@ Requires-Dist: httpx<1.0,>=0.28.1
20
20
  Requires-Dist: paho-mqtt<3.0.0,>=2.0.0
21
21
  Requires-Dist: pyyaml>=6.0.0
22
22
  Provides-Extra: schema-0
23
- Requires-Dist: span-panel-api-schema-0>=1.2.0; extra == 'schema-0'
23
+ Requires-Dist: span-panel-api-schema-0>=1.3.0b1; extra == 'schema-0'
24
24
  Provides-Extra: schema-1
25
- Requires-Dist: span-panel-api-schema-1>=1.2.2; extra == 'schema-1'
25
+ Requires-Dist: span-panel-api-schema-1>=1.3.0b1; extra == 'schema-1'
26
26
  Description-Content-Type: text/markdown
27
27
 
28
28
  # SPAN Panel API
@@ -175,8 +175,8 @@ transport-specific classes.
175
175
 
176
176
  All panel state is represented as immutable, frozen dataclasses:
177
177
 
178
- - **`SpanPanelSnapshot`**: complete panel state. Identity (serial, firmware, vendor, model, hardware version), panel size, main breaker rating, main relay, door and proximity state, uptime, network links with the Wi-Fi SSID and vendor cloud, grid and
179
- 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`,
178
+ - **`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
180
  `battery`, `pv`, `pv_inverters`, `evse`, `mid`, `pcs`, `adopted_devices` and `extension_properties`.
181
181
  - **`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
182
  position, PCS management and priority, and the `$target` pending state for relay and priority.
@@ -195,6 +195,9 @@ see the changelog, because `battery.model` changes value for existing flat users
195
195
 
196
196
  `mid`, `pcs`, `adopted_devices`, `extension_properties` and the per-DER link-health fields exist only under the parent/child schema. They are `None` or empty on a flat panel rather than absent, so a consumer reads the same snapshot type either way.
197
197
 
198
+ `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
+ answer is `V2StatusInfo.proximity_proven`, from `GET /api/v2/status`.
200
+
198
201
  ## Usage
199
202
 
200
203
  ### Factory Pattern (Recommended)
@@ -148,8 +148,8 @@ transport-specific classes.
148
148
 
149
149
  All panel state is represented as immutable, frozen dataclasses:
150
150
 
151
- - **`SpanPanelSnapshot`**: complete panel state. Identity (serial, firmware, vendor, model, hardware version), panel size, main breaker rating, main relay, door and proximity state, uptime, network links with the Wi-Fi SSID and vendor cloud, grid and
152
- 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`,
151
+ - **`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
153
  `battery`, `pv`, `pv_inverters`, `evse`, `mid`, `pcs`, `adopted_devices` and `extension_properties`.
154
154
  - **`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
155
  position, PCS management and priority, and the `$target` pending state for relay and priority.
@@ -168,6 +168,9 @@ see the changelog, because `battery.model` changes value for existing flat users
168
168
 
169
169
  `mid`, `pcs`, `adopted_devices`, `extension_properties` and the per-DER link-health fields exist only under the parent/child schema. They are `None` or empty on a flat panel rather than absent, so a consumer reads the same snapshot type either way.
170
170
 
171
+ `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
+ answer is `V2StatusInfo.proximity_proven`, from `GET /api/v2/status`.
173
+
171
174
  ## Usage
172
175
 
173
176
  ### Factory Pattern (Recommended)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "3.6.2"
3
+ version = "3.7.0b1"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -62,11 +62,14 @@ 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
- schema-0 = ["span-panel-api-schema-0>=1.2.0"]
65
+ # Raised to 1.3.0b1 for 3.7.0b1: that adapter reports the unknown values this
66
+ # bootstrap's snapshot fields now accept, and stops synthesising unoccupied positions.
67
+ schema-0 = ["span-panel-api-schema-0>=1.3.0b1"]
66
68
  # Raised to 1.2.1 for 3.6.1: that adapter builds the `pv` that `SpanPanelSnapshot.pv` now documents.
67
69
  # Raised to 1.2.2 for 3.6.2: the readiness gate every snapshot passes asks that
68
70
  # adapter what is still missing, and 1.2.2 restarts its feed grace correctly.
69
- schema-1 = ["span-panel-api-schema-1>=1.2.2"]
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"]
70
73
 
71
74
  [project.urls]
72
75
  Homepage = "https://github.com/SpanPanel/span-panel-api"
@@ -245,7 +248,11 @@ warn_return_any = true
245
248
  warn_unused_configs = true
246
249
  disallow_untyped_defs = true
247
250
  explicit_package_bases = true
248
- mypy_path = "src"
251
+ # `tests` because the suite imports its support packages top-level
252
+ # (`from reference_payloads.schema_one import ...`), the way pytest's rootdir puts
253
+ # `tests/` on `sys.path`; the support packages the mypy hook checks resolve the same
254
+ # way here.
255
+ mypy_path = "src:tests"
249
256
  # Excluded by name for the reason `[tool.ruff]` gives above: the capture script is
250
257
  # checked like the library, and the hand-run tools written before that was expected
251
258
  # are not.
@@ -9,7 +9,7 @@ client (which only covers v1 endpoints).
9
9
  from __future__ import annotations
10
10
 
11
11
  import asyncio
12
- from collections.abc import Collection
12
+ from collections.abc import Collection, Mapping
13
13
  import hashlib
14
14
  import json
15
15
  import logging
@@ -454,6 +454,32 @@ async def download_ca_cert(
454
454
  raise SpanPanelAPIError(f"Failed to download CA cert: HTTP {last_status}")
455
455
 
456
456
 
457
+ def _schema_hash(data: Mapping[str, object]) -> str:
458
+ """The schema hash ``V2HomieSchema.types_schema_hash`` reports.
459
+
460
+ A parent/child panel publishes its schema as ``deviceClasses``, with its own
461
+ ``deviceClassesSchemaHash`` over that block, in place of flat firmware's
462
+ ``types``. Hashing ``types`` there hashed an absent block, so every such
463
+ panel got one constant key. The published hash is taken as is when it is a
464
+ non-empty string; otherwise ``deviceClasses`` is hashed here.
465
+
466
+ SPAN's public API changelog states that ``deviceClassesSchemaHash`` is
467
+ computed over the new structure, so it differs from a ``typesSchemaHash``
468
+ even where the property content is unchanged, and that a cached
469
+ ``typesSchemaHash`` is not to be compared against it. A key held from a
470
+ release that hashed ``types`` therefore differs once on such a panel.
471
+
472
+ A flat panel's key is exactly what earlier releases computed, so a value
473
+ stored for a flat panel still matches.
474
+ """
475
+ published = data.get("deviceClassesSchemaHash")
476
+ if isinstance(published, str) and published:
477
+ return published
478
+ hashed = data["deviceClasses"] if "deviceClasses" in data else data.get("types", {})
479
+ canonical = json.dumps(hashed, sort_keys=True)
480
+ return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()[:16]
481
+
482
+
457
483
  async def get_homie_schema(
458
484
  host: str,
459
485
  timeout: float = 10.0,
@@ -527,15 +553,9 @@ async def get_homie_schema(
527
553
  if isinstance(props, dict):
528
554
  types[str(type_name)] = {str(k): v for k, v in props.items()}
529
555
 
530
- # Compute schema hash from types key names for change detection
531
- # The panel provides this implicitly via the firmware version + types structure
532
- # We derive a hash for caching; the fixture README documents the expected value
533
- types_json = json.dumps(data.get("types", {}), sort_keys=True)
534
- schema_hash = "sha256:" + hashlib.sha256(types_json.encode()).hexdigest()[:16]
535
-
536
556
  # Read before anything else interprets the payload. A parent/child response
537
- # carries `deviceClasses` where this one reads `types`, so every field below
538
- # degrades to empty for such a panel — which is harmless only because this
557
+ # carries `deviceClasses` where this one reads `types`, so `types` degrades
558
+ # to empty for such a panel — which is harmless only because this
539
559
  # value routes it to a different parser before those fields are used.
540
560
  # Absence is the flat signal and must stay distinct from an empty string.
541
561
  raw_data_model_version = data.get("dataModelVersion")
@@ -543,7 +563,7 @@ async def get_homie_schema(
543
563
 
544
564
  return V2HomieSchema(
545
565
  firmware_version=str(data.get("firmwareVersion", "")),
546
- types_schema_hash=schema_hash,
566
+ types_schema_hash=_schema_hash(data),
547
567
  types=types,
548
568
  data_model_version=data_model_version,
549
569
  )
@@ -49,8 +49,12 @@ class SpanCircuitSnapshot:
49
49
  breaker_rating_a: float | None = None
50
50
  always_on: bool = False # v2 new: circuit/always-on
51
51
  relay_requester: str = "UNKNOWN" # v2 new: circuit/relay-requester
52
- energy_accum_update_time_s: int = 0 # v1: poll timestamp | v2: MQTT arrival time
53
- instant_power_update_time_s: int = 0 # v1: poll timestamp | v2: MQTT arrival time
52
+ # Epoch seconds at which the reading last changed, as received over MQTT.
53
+ # Flat only: the tree adapter records no such time, and a reading that has
54
+ # not arrived has no time either, so both are `None` rather than a `0` that
55
+ # reads as 1970.
56
+ energy_accum_update_time_s: int | None = None # flat: MQTT receipt time
57
+ instant_power_update_time_s: int | None = None # flat: MQTT receipt time
54
58
  relay_state_target: str | None = None # v2: $target for relay (desired state)
55
59
  priority_target: str | None = None # v2: $target for shed-priority (desired state)
56
60
 
@@ -210,8 +214,10 @@ class SpanMidSnapshot:
210
214
 
211
215
  The raw value above is a Homie device id, which means nothing on a dashboard — it is
212
216
  not a Home Assistant device id, and an opaque string is worse than none. This is the
213
- device's own `$description.name` (`Battery`, `Solar`, `SPAN Drive - Garage`), which
214
- is the part a person can read. The literal stays available beside it.
217
+ device's own `$description.name` (`Battery`, `Solar`, `SPAN Drive - Garage`) where
218
+ that is not the id itself, else its vendor and model (`Tesla Powerwall 2 AC`), which
219
+ is the part a person can read; `None` when it publishes nothing that names it. The
220
+ literal stays available beside it.
215
221
  """
216
222
 
217
223
 
@@ -616,6 +622,7 @@ class V2StatusInfo:
616
622
  serial_number: str
617
623
  firmware_version: str
618
624
  proximity_proven: bool | None = None # Added in firmware 202609; None on older panels
625
+ hardware_version: str | None = None # Added in firmware r202639: `1.2`, `2.0` or `UNKNOWN`; None on older panels
619
626
 
620
627
  @classmethod
621
628
  def from_status_payload(cls, payload: Mapping[str, object]) -> V2StatusInfo:
@@ -633,16 +640,21 @@ class V2StatusInfo:
633
640
  panel that may not fully support it, so a partial body is information,
634
641
  not a failure.
635
642
 
636
- ``proximity_proven`` is the exception and stays ``None`` unless the panel
643
+ ``proximity_proven`` is an exception and stays ``None`` unless the panel
637
644
  published a real boolean. Absent and false are different facts there —
638
645
  firmware below 202609 does not report it at all — and coercing whatever
639
- arrived would turn a string ``"false"`` into ``True``.
646
+ arrived would turn a string ``"false"`` into ``True``. ``hardware_version``
647
+ is likewise ``None`` unless the panel published a real string: firmware
648
+ below r202639 omits it, and a stringified number or object would read as
649
+ a version the panel never reported.
640
650
  """
641
651
  raw_proximity = payload.get("proximityProven")
652
+ raw_hardware_version = payload.get("hardwareVersion")
642
653
  return cls(
643
654
  serial_number=str(payload.get("serialNumber", "")),
644
655
  firmware_version=str(payload.get("firmwareVersion", "")),
645
656
  proximity_proven=raw_proximity if isinstance(raw_proximity, bool) else None,
657
+ hardware_version=raw_hardware_version if isinstance(raw_hardware_version, str) else None,
646
658
  )
647
659
 
648
660
 
@@ -654,7 +666,10 @@ class V2HomieSchema:
654
666
  """Response from GET /api/v2/homie/schema."""
655
667
 
656
668
  firmware_version: str
657
- types_schema_hash: str # SHA-256, first 16 hex chars
669
+ # SHA-256, first 16 hex chars, compared by the client between connects: over
670
+ # ``types`` on a flat panel, and over ``deviceClasses`` on parent/child, where
671
+ # the panel's own ``deviceClassesSchemaHash`` is used when it publishes one.
672
+ types_schema_hash: str
658
673
  types: HomieSchemaTypes
659
674
  # The flat-vs-parent/child discriminator, and the reason this endpoint is
660
675
  # fetched before MQTT is opened rather than during connect(). Absent on flat
@@ -1072,8 +1087,16 @@ class SpanPanelSnapshot:
1072
1087
 
1073
1088
  # Hardware status — v1 field names preserved
1074
1089
  door_state: str # v1: direct | v2: core/door
1075
- proximity_proven: bool # v1: proximity sensor | v2: MQTT auth + $state==ready
1076
- uptime_s: int # v1: panel uptime | v2: connection uptime since $state==ready
1090
+ # Neither MQTT schema publishes a proximity property, so a snapshot never
1091
+ # knows: always `None`. Being connected and ready proves the client holds
1092
+ # broker credentials, not that anyone stood at the panel. The panel's own
1093
+ # answer is `V2StatusInfo.proximity_proven`, from the REST status endpoint.
1094
+ proximity_proven: bool | None
1095
+ # Seconds since this client last saw the panel's `$state` turn `ready`: a
1096
+ # connection uptime measured here, not the panel's own uptime, which no
1097
+ # schema publishes. Flat only; `None` from the tree adapter, and before the
1098
+ # first `ready`.
1099
+ uptime_s: int | None
1077
1100
  eth0_link: bool # v1: direct | v2: core/ethernet
1078
1101
  wlan_link: bool # v1: direct | v2: core/wifi
1079
1102
  wwan_link: bool # v1: direct | v2: vendor-cloud == "CONNECTED"
@@ -846,8 +846,7 @@ class AsyncMqttBridge:
846
846
  retrying with it.
847
847
 
848
848
  Recovery target: CA rotation (firmware upgrade), stale paho client
849
- internal state, stuck Homie accumulator. See the design doc at
850
- SpanPanel_Docs/span-panel-api/2026-05-17-mqtt-ca-refresh-on-reconnect-design.md.
849
+ internal state, stuck Homie accumulator.
851
850
  """
852
851
  if self._loop is None:
853
852
  return False
@@ -0,0 +1,5 @@
1
+ """Adapter fidelity: captured device trees replayed through the schema-1 adapter.
2
+
3
+ `harness` measures one capture and names no panel, model or capture; `rules`
4
+ documents why each measured outcome is right; the test modules pick captures.
5
+ """