span-panel-api 3.6.1b1__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.
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/CHANGELOG.md +48 -9
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/PKG-INFO +8 -5
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/README.md +5 -2
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/pyproject.toml +14 -5
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/__init__.py +1 -1
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/auth.py +30 -10
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/models.py +32 -9
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/mqtt/client.py +146 -29
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/mqtt/connection.py +1 -2
- span_panel_api-3.7.0b1/tests/adapter_fidelity/__init__.py +5 -0
- span_panel_api-3.7.0b1/tests/adapter_fidelity/harness.py +858 -0
- span_panel_api-3.7.0b1/tests/adapter_fidelity/rules.py +407 -0
- span_panel_api-3.7.0b1/tests/adapter_fidelity/test_harness.py +194 -0
- span_panel_api-3.7.0b1/tests/adapter_fidelity/test_main32_fidelity.py +172 -0
- span_panel_api-3.7.0b1/tests/fixtures/distribution-enclosure-simulator.LICENSE +21 -0
- span_panel_api-3.7.0b1/tests/fixtures/main32-tree-v1.README.md +9 -0
- span_panel_api-3.7.0b1/tests/fixtures/main32-tree-v1.json +3748 -0
- span_panel_api-3.7.0b1/tests/fixtures/main32_r202639-tree-v1.README.md +8 -0
- span_panel_api-3.7.0b1/tests/fixtures/main32_r202639-tree-v1.json +3292 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_absent_readings_are_not_zero.py +0 -15
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_accumulator.py +3 -2
- span_panel_api-3.7.0b1/tests/test_homie_schema_hash.py +87 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_mqtt_client_connection.py +18 -2
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_mqtt_homie.py +68 -196
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_public_api_unchanged.py +1 -1
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_register_passphrase_unavailable.py +5 -2
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_migration_delta.py +28 -7
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_adapter.py +44 -4
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_circuits.py +6 -6
- span_panel_api-3.7.0b1/tests/test_schema_one_device_display_name.py +91 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_panel.py +2 -40
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_pcs.py +0 -10
- span_panel_api-3.7.0b1/tests/test_schema_one_sheddable.py +46 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_snapshot.py +21 -22
- span_panel_api-3.7.0b1/tests/test_schema_one_undeclared_devices.py +166 -0
- span_panel_api-3.7.0b1/tests/test_snapshot_readiness_gate.py +544 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_v2_status_parser.py +36 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/.gitignore +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/LICENSE +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/_ssl.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/conftest.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/fixtures/panelbench_wire.json +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/reference_payloads/README.md +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/reference_payloads/bootstrap.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/reference_payloads/schema_one.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_adoption.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_ca_pinning.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_https_transport.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_leaf_name_mismatch.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_packaging.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_plaintext_warning.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_rest_transport_contract.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_fetch_transport_split.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_control_refusal.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_discovery.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_firmware.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/test_ssl_context.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.7.0b1}/tests/tls_fixtures.py +0 -0
|
@@ -7,21 +7,55 @@ 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
|
-
## [
|
|
10
|
+
## [Unreleased]
|
|
11
11
|
|
|
12
|
-
|
|
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`.**
|
|
13
17
|
|
|
14
18
|
### Changed
|
|
15
19
|
|
|
16
|
-
-
|
|
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.
|
|
17
26
|
|
|
18
|
-
|
|
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
|
+
|
|
37
|
+
## [3.6.2]
|
|
38
|
+
|
|
39
|
+
A reconnect waits for the panel's labels before handing out a snapshot, as connecting always has.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **`get_snapshot()` raises `SpanPanelStaleDataError` while a rebuilt tree's names and feed links are still arriving**, as it already did while the tree itself was, and for at most the same wait connecting allows.
|
|
44
|
+
- **`connect()` no longer raises an adapter's error from its wait for the panel's labels**, logging it at ERROR and serving the tree as it is, and only connect warns about labels that never arrive, while a reconnect logs that timeout at DEBUG.
|
|
45
|
+
- **The `schema-1` extra requires `span-panel-api-schema-1` 1.2.2 or newer**, the adapter that restarts its feed-link wait correctly.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **A reconnect no longer produces a snapshot in which a PV inverter is keyed by its device id or its feeding circuit reads as a load**, because after the broker connection is rebuilt no snapshot is dispatched until the replayed tree's circuit names and
|
|
50
|
+
feed links have arrived.
|
|
51
|
+
|
|
52
|
+
## [3.6.1]
|
|
19
53
|
|
|
20
54
|
The snapshot carries every PV inverter a panel commissions, and registration copes with a panel that cannot read its own passphrase.
|
|
21
55
|
|
|
22
56
|
### Added
|
|
23
57
|
|
|
24
|
-
- **`SpanPanelSnapshot.pv_inverters`** carries every commissioned PV inverter, keyed by its feeding circuit's id, which stays put when firmware r202639
|
|
58
|
+
- **`SpanPanelSnapshot.pv_inverters`** carries every commissioned PV inverter, keyed by its feeding circuit's id, which stays put when firmware r202639 changes a panel's PV device ids, or by its device id where no circuit feeds it.
|
|
25
59
|
- **`SpanPVSnapshot.serial_number`, `device_id` and `node_id`** give an inverter's serial number when one is published, its id on the wire, and its key in `pv_inverters`.
|
|
26
60
|
- **`SpanEvseSnapshot.effective_charge_current_limit_a`** is the charge-current limit a charger is applying, its user limit when one is published and its ceiling otherwise, since from firmware r202639 a SPAN Drive publishes a user limit only once someone
|
|
27
61
|
sets one.
|
|
@@ -29,10 +63,16 @@ The snapshot carries every PV inverter a panel commissions, and registration cop
|
|
|
29
63
|
|
|
30
64
|
### Changed
|
|
31
65
|
|
|
66
|
+
- **`SpanPanelSnapshot.pv` describes the inverters together when more than one is commissioned**, carrying only their shared vendor and model, the sum of their installed DC sizes and a link that is down if any is down, rather than whichever one the adapter
|
|
67
|
+
met first.
|
|
68
|
+
- **`SpanPVSnapshot.nameplate_capacity_w` is documented as the array's DC size recorded at installation**, an informational figure and never a ceiling on PV power.
|
|
32
69
|
- **`V2AuthResponse.ebus_broker_password` and `hop_passphrase` are `str | None`**, `None` when a panel on firmware r202639 or later cannot read its passphrase yet still issues a valid access token.
|
|
33
70
|
- **`register_v2` raises `SpanPanelServerError` with `status_code` for any 5xx**, including the 503 a panel on firmware r202639 answers until it knows its serial number, where it raised a plain `SpanPanelAPIError`.
|
|
34
|
-
-
|
|
35
|
-
|
|
71
|
+
- **The `schema-0` and `schema-1` extras require `span-panel-api-schema-0` 1.2.0 and `span-panel-api-schema-1` 1.2.1 or newer**, the adapters that fill `pv_inverters`.
|
|
72
|
+
|
|
73
|
+
## [3.6.0] [YANKED]
|
|
74
|
+
|
|
75
|
+
Withdrawn from PyPI; 3.6.1 replaces it.
|
|
36
76
|
|
|
37
77
|
## [3.5.0]
|
|
38
78
|
|
|
@@ -467,8 +507,7 @@ Everything below is additive. Each field is `None` or empty on a panel that publ
|
|
|
467
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
|
|
468
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
|
|
469
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
|
|
470
|
-
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.
|
|
471
|
-
`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.
|
|
472
511
|
|
|
473
512
|
### Added
|
|
474
513
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 3.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
version = "3.7.0b1"
|
|
4
4
|
description = "A client library for SPAN Panel API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "SpanPanel"}
|
|
@@ -62,9 +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
|
-
|
|
66
|
-
#
|
|
67
|
-
schema-
|
|
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"]
|
|
68
|
+
# Raised to 1.2.1 for 3.6.1: that adapter builds the `pv` that `SpanPanelSnapshot.pv` now documents.
|
|
69
|
+
# Raised to 1.2.2 for 3.6.2: the readiness gate every snapshot passes asks that
|
|
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"]
|
|
68
73
|
|
|
69
74
|
[project.urls]
|
|
70
75
|
Homepage = "https://github.com/SpanPanel/span-panel-api"
|
|
@@ -243,7 +248,11 @@ warn_return_any = true
|
|
|
243
248
|
warn_unused_configs = true
|
|
244
249
|
disallow_untyped_defs = true
|
|
245
250
|
explicit_package_bases = true
|
|
246
|
-
|
|
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"
|
|
247
256
|
# Excluded by name for the reason `[tool.ruff]` gives above: the capture script is
|
|
248
257
|
# checked like the library, and the hand-run tools written before that was expected
|
|
249
258
|
# are not.
|
|
@@ -211,7 +211,7 @@ __all__ = [ # noqa: RUF022
|
|
|
211
211
|
# Added 2026-10-02 (3.5.0): a 403 from a reduced-privilege token. A subclass
|
|
212
212
|
# of SpanPanelAuthError, so every existing except clause keeps its meaning.
|
|
213
213
|
"SpanPanelInsufficientPrivilegeError",
|
|
214
|
-
# Added 2026-10-06 (3.6.
|
|
214
|
+
# Added 2026-10-06 (3.6.1): registration reached a panel that cannot read its own
|
|
215
215
|
# passphrase. A subclass of SpanPanelAPIError, so existing except clauses
|
|
216
216
|
# keep their meaning; deliberately not a SpanPanelAuthError, because the
|
|
217
217
|
# passphrase the user supplied may be correct.
|
|
@@ -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
|
|
538
|
-
#
|
|
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=
|
|
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
|
-
|
|
53
|
-
|
|
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`)
|
|
214
|
-
is the
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1076
|
-
|
|
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"
|
|
@@ -193,6 +193,16 @@ class SpanMqttClient:
|
|
|
193
193
|
self._schema_change_callbacks: list[Callable[[str | None, str | None], None]] = []
|
|
194
194
|
self._live = False
|
|
195
195
|
self._ready_event: asyncio.Event | None = None
|
|
196
|
+
# The readiness gate's latch. A tree is dispatchable once it is complete
|
|
197
|
+
# *and* its labels have settled, and the second half is a wait rather than
|
|
198
|
+
# a predicate: `_settle` polls the adapter until nothing is missing or the
|
|
199
|
+
# wait runs out, then records the adapter it settled here. Keyed on the
|
|
200
|
+
# adapter instance, so every new tree -- connect, a bridge rebuild, a
|
|
201
|
+
# generation swap -- starts unsettled without anything having to reset it.
|
|
202
|
+
self._settled_adapter: SchemaAdapter | None = None
|
|
203
|
+
self._settling_adapter: SchemaAdapter | None = None
|
|
204
|
+
self._settle_task: asyncio.Task[None] | None = None
|
|
205
|
+
self._has_settled = False
|
|
196
206
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
197
207
|
self._background_tasks: set[asyncio.Task[None]] = set()
|
|
198
208
|
self._snapshot_timer: asyncio.TimerHandle | None = None
|
|
@@ -513,12 +523,19 @@ class SpanMqttClient:
|
|
|
513
523
|
await self.close()
|
|
514
524
|
raise SpanPanelConnectionError(f"Timed out waiting for Homie device ready ({self._serial_number})") from exc
|
|
515
525
|
|
|
516
|
-
_LOGGER.debug("MQTT: Homie device ready, waiting for
|
|
526
|
+
_LOGGER.debug("MQTT: Homie device ready, waiting for its labels...")
|
|
517
527
|
|
|
518
|
-
#
|
|
519
|
-
#
|
|
520
|
-
#
|
|
521
|
-
|
|
528
|
+
# The tree's labels arrive as retained values that may land after it is
|
|
529
|
+
# complete. The ready edge in `_on_message` started the one wait every
|
|
530
|
+
# tree goes through, so connect returns only once the first snapshot a
|
|
531
|
+
# caller can take carries them.
|
|
532
|
+
# `wait` rather than awaiting the task: `close()` cancels it, and that must
|
|
533
|
+
# end this wait rather than surface here as a cancellation of connect().
|
|
534
|
+
settle = self._settle_task
|
|
535
|
+
if settle is not None:
|
|
536
|
+
await asyncio.wait([settle])
|
|
537
|
+
if not settle.cancelled():
|
|
538
|
+
settle.result()
|
|
522
539
|
|
|
523
540
|
self._assert_transports_agree_on_schema_generation()
|
|
524
541
|
_LOGGER.debug("MQTT: Connection fully established")
|
|
@@ -755,6 +772,8 @@ class SpanMqttClient:
|
|
|
755
772
|
raise SpanPanelStaleDataError("MQTT broker disconnected")
|
|
756
773
|
if not self._adapter.is_ready():
|
|
757
774
|
raise SpanPanelStaleDataError("Homie device not ready")
|
|
775
|
+
if not self._snapshot_ready(self._adapter):
|
|
776
|
+
raise SpanPanelStaleDataError("Homie device not ready: its labels are still arriving")
|
|
758
777
|
return self._adapter.build_snapshot()
|
|
759
778
|
|
|
760
779
|
# -- CircuitControlProtocol --------------------------------------------
|
|
@@ -1370,11 +1389,13 @@ class SpanMqttClient:
|
|
|
1370
1389
|
adapter.handle_message(topic, payload)
|
|
1371
1390
|
|
|
1372
1391
|
# Check if device just became ready
|
|
1373
|
-
if not was_ready and adapter.is_ready()
|
|
1374
|
-
self._ready_event
|
|
1392
|
+
if not was_ready and adapter.is_ready():
|
|
1393
|
+
if self._ready_event is not None:
|
|
1394
|
+
self._ready_event.set()
|
|
1395
|
+
self._start_settling(adapter)
|
|
1375
1396
|
|
|
1376
1397
|
# Dispatch snapshot callbacks if streaming
|
|
1377
|
-
if self._streaming and
|
|
1398
|
+
if self._streaming and self._snapshot_ready(adapter) and self._loop is not None:
|
|
1378
1399
|
if self._snapshot_interval <= 0:
|
|
1379
1400
|
# Real-time mode — dispatch immediately, no debounce.
|
|
1380
1401
|
self._create_dispatch_task()
|
|
@@ -1693,6 +1714,8 @@ class SpanMqttClient:
|
|
|
1693
1714
|
old paho client is torn down and the new one is wired up. Discards
|
|
1694
1715
|
any stale `$state=disconnected` cached during the outage so the
|
|
1695
1716
|
new subscription's retained messages repopulate from a clean slate.
|
|
1717
|
+
The fresh adapter is unsettled by construction, so nothing is
|
|
1718
|
+
dispatched from it until its labels have passed the readiness gate.
|
|
1696
1719
|
|
|
1697
1720
|
Schema-derived state (`_schema`, `_schema_hash`,
|
|
1698
1721
|
`_previous_schema_types`) is intentionally preserved — the Homie
|
|
@@ -1719,29 +1742,122 @@ class SpanMqttClient:
|
|
|
1719
1742
|
_LOGGER.debug("Pre-rebuild — resetting Homie accumulator")
|
|
1720
1743
|
self._build_adapter(self._schema)
|
|
1721
1744
|
|
|
1722
|
-
|
|
1723
|
-
|
|
1745
|
+
# -- The readiness gate ------------------------------------------------
|
|
1746
|
+
#
|
|
1747
|
+
# One gate for every snapshot this client hands out, whichever path built the
|
|
1748
|
+
# tree it comes from. A tree is complete once every device has described
|
|
1749
|
+
# itself; its labels -- circuit names, DER models, the feed links that key an
|
|
1750
|
+
# inverter and type its circuit -- are separate retained values that a broker
|
|
1751
|
+
# may replay after the last description. `connect()` used to wait those out
|
|
1752
|
+
# on its own, so the first snapshot after a bridge rebuild was dispatched from
|
|
1753
|
+
# a tree that had its shape and not its labels: an inverter keyed by its
|
|
1754
|
+
# device id, and its feeding circuit read as a load, for one dispatch.
|
|
1755
|
+
#
|
|
1756
|
+
# The adapter answers what is still missing (`circuit_nodes_missing_names`,
|
|
1757
|
+
# which also bounds the feed wait with its own grace). The waiting itself
|
|
1758
|
+
# lives here, once, and runs for every tree from its ready edge.
|
|
1759
|
+
|
|
1760
|
+
def _snapshot_ready(self, adapter: SchemaAdapter) -> bool:
|
|
1761
|
+
"""Whether `adapter`'s tree may be handed out: complete, and its labels settled."""
|
|
1762
|
+
return adapter.is_ready() and self._settled_adapter is adapter
|
|
1763
|
+
|
|
1764
|
+
def _start_settling(self, adapter: SchemaAdapter) -> None:
|
|
1765
|
+
"""Start the label wait for `adapter`'s tree, unless it is settled or already settling.
|
|
1766
|
+
|
|
1767
|
+
Called on every ready edge. A tree can drop out of readiness and return
|
|
1768
|
+
within one adapter's life -- a device commissioned mid-session is
|
|
1769
|
+
undescribed for a moment -- and that is not a new tree, so it neither
|
|
1770
|
+
restarts a wait in progress nor reopens a settled one.
|
|
1771
|
+
"""
|
|
1772
|
+
if self._loop is None or self._settled_adapter is adapter or self._settling_adapter is adapter:
|
|
1773
|
+
return
|
|
1774
|
+
self._settling_adapter = adapter
|
|
1775
|
+
task = self._loop.create_task(self._settle(adapter), name="span_mqtt_settle_labels")
|
|
1776
|
+
self._settle_task = task
|
|
1777
|
+
self._background_tasks.add(task)
|
|
1778
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
1779
|
+
|
|
1780
|
+
async def _settle(self, adapter: SchemaAdapter) -> None:
|
|
1781
|
+
"""Wait out `adapter`'s labels, open the gate for it, and dispatch what it now holds.
|
|
1782
|
+
|
|
1783
|
+
A tree replaced while this waited -- a second rebuild, a generation swap --
|
|
1784
|
+
is left unsettled: its successor has a wait of its own.
|
|
1724
1785
|
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1786
|
+
The dispatch is not optional. The replay is a burst, and a panel can be
|
|
1787
|
+
quiet once it ends; without it a streaming consumer would see nothing from
|
|
1788
|
+
the rebuilt tree until some value happened to change.
|
|
1789
|
+
|
|
1790
|
+
The gate always opens. A wait that times out serves the tree as it is, and
|
|
1791
|
+
an adapter that raises is waited out to the same deadline (see
|
|
1792
|
+
`_wait_for_circuit_names`). The guard here is the last resort behind that:
|
|
1793
|
+
nothing awaits a rebuild's settle, so an exception escaping the wait itself
|
|
1794
|
+
would close the gate for the tree's whole life without a word.
|
|
1795
|
+
Cancellation is not a failure and still propagates; `close()` relies on it.
|
|
1796
|
+
|
|
1797
|
+
Only the client's first wait, which is connect's, reports a timeout as a
|
|
1798
|
+
warning. A label that is never published would otherwise be warned about
|
|
1799
|
+
again on every broker reconnect, and the user can act on it once.
|
|
1800
|
+
"""
|
|
1801
|
+
first = not self._has_settled
|
|
1802
|
+
self._has_settled = True
|
|
1803
|
+
try:
|
|
1804
|
+
await self._wait_for_circuit_names(
|
|
1805
|
+
adapter,
|
|
1806
|
+
timeout=_CIRCUIT_NAMES_TIMEOUT_S,
|
|
1807
|
+
timeout_level=logging.WARNING if first else logging.DEBUG,
|
|
1808
|
+
)
|
|
1809
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
1810
|
+
_LOGGER.error("Could not tell whether the panel's labels have arrived; serving its tree as it is", exc_info=True)
|
|
1811
|
+
if self._adapter is not adapter:
|
|
1812
|
+
return
|
|
1813
|
+
self._settled_adapter = adapter
|
|
1814
|
+
if self._streaming:
|
|
1815
|
+
self._create_dispatch_task()
|
|
1816
|
+
|
|
1817
|
+
async def _wait_for_circuit_names(self, adapter: SchemaAdapter, timeout: float, timeout_level: int) -> None:
|
|
1818
|
+
"""Wait until `adapter` reports nothing missing, or the timeout elapses.
|
|
1819
|
+
|
|
1820
|
+
Polls `circuit_nodes_missing_names` at short intervals rather than
|
|
1821
|
+
reacting to messages, because the adapter's feed grace is measured on
|
|
1822
|
+
its own clock and must be able to expire while the panel is silent.
|
|
1823
|
+
A timeout is not fatal: entities fall back to placeholder names, and
|
|
1824
|
+
what is still missing is logged at `timeout_level`.
|
|
1825
|
+
|
|
1826
|
+
A poll that raises counts as one that found labels missing, and the
|
|
1827
|
+
wait goes on to its deadline. A transient fault then costs nothing, and
|
|
1828
|
+
cannot open the gate early on whatever names happen to have arrived --
|
|
1829
|
+
which, on a first install, a consumer turns into permanent entity ids.
|
|
1830
|
+
The fault is reported at ERROR once per wait, with its exception.
|
|
1729
1831
|
"""
|
|
1730
|
-
adapter = self._require_adapter()
|
|
1731
1832
|
deadline = time.monotonic() + timeout
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1833
|
+
fault_reported = False
|
|
1834
|
+
while True:
|
|
1835
|
+
missing: list[str] | None
|
|
1836
|
+
try:
|
|
1837
|
+
missing = adapter.circuit_nodes_missing_names()
|
|
1838
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
1839
|
+
missing = None
|
|
1840
|
+
if not fault_reported:
|
|
1841
|
+
_LOGGER.error(
|
|
1842
|
+
"Could not ask the parser which of the panel's labels are missing; waiting them out",
|
|
1843
|
+
exc_info=True,
|
|
1844
|
+
)
|
|
1845
|
+
fault_reported = True
|
|
1846
|
+
if missing is not None and not missing:
|
|
1735
1847
|
_LOGGER.debug("All circuit names received")
|
|
1736
1848
|
return
|
|
1849
|
+
if time.monotonic() >= deadline:
|
|
1850
|
+
break
|
|
1737
1851
|
await asyncio.sleep(_CIRCUIT_NAMES_POLL_INTERVAL_S)
|
|
1738
1852
|
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1853
|
+
if missing is None:
|
|
1854
|
+
_LOGGER.log(timeout_level, "Timed out waiting for circuit names (the parser could not say which are missing)")
|
|
1855
|
+
else:
|
|
1856
|
+
_LOGGER.log(
|
|
1857
|
+
timeout_level,
|
|
1742
1858
|
"Timed out waiting for circuit names (%d still missing): %s",
|
|
1743
|
-
len(
|
|
1744
|
-
|
|
1859
|
+
len(missing),
|
|
1860
|
+
missing[:5],
|
|
1745
1861
|
)
|
|
1746
1862
|
|
|
1747
1863
|
def _create_dispatch_task(self) -> None:
|
|
@@ -1783,18 +1899,19 @@ class SpanMqttClient:
|
|
|
1783
1899
|
"""Build snapshot and send to all registered callbacks.
|
|
1784
1900
|
|
|
1785
1901
|
Guarded by the same liveness predicate as get_snapshot() — if the
|
|
1786
|
-
bridge has disconnected or the
|
|
1787
|
-
dispatch occurs. This prevents a pending debounce timer
|
|
1788
|
-
scheduled just before a disconnect from delivering
|
|
1789
|
-
snapshot to subscribers after the fact.
|
|
1902
|
+
bridge has disconnected, or the tree is not ready or its labels have
|
|
1903
|
+
not settled, no dispatch occurs. This prevents a pending debounce timer
|
|
1904
|
+
that was scheduled just before a disconnect or a rebuild from delivering
|
|
1905
|
+
a stale or half-replayed snapshot to subscribers after the fact.
|
|
1790
1906
|
"""
|
|
1791
1907
|
bridge = self._bridge
|
|
1792
1908
|
adapter = self._adapter
|
|
1793
|
-
if bridge is None or not bridge.is_connected() or adapter is None or not
|
|
1909
|
+
if bridge is None or not bridge.is_connected() or adapter is None or not self._snapshot_ready(adapter):
|
|
1794
1910
|
_LOGGER.debug(
|
|
1795
|
-
"Skipping stale snapshot dispatch (bridge_connected=%s, homie_ready=%s)",
|
|
1911
|
+
"Skipping stale snapshot dispatch (bridge_connected=%s, homie_ready=%s, labels_settled=%s)",
|
|
1796
1912
|
bridge is not None and bridge.is_connected(),
|
|
1797
1913
|
adapter is not None and adapter.is_ready(),
|
|
1914
|
+
adapter is not None and self._settled_adapter is adapter,
|
|
1798
1915
|
)
|
|
1799
1916
|
return
|
|
1800
1917
|
snapshot = adapter.build_snapshot()
|
|
@@ -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.
|
|
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
|