span-panel-api 3.6.1b1__tar.gz → 3.6.2__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.6.2}/CHANGELOG.md +20 -7
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/PKG-INFO +2 -2
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/pyproject.toml +5 -3
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/__init__.py +1 -1
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/mqtt/client.py +146 -29
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_mqtt_client_connection.py +17 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_mqtt_homie.py +3 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_public_api_unchanged.py +1 -1
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_adapter.py +40 -0
- span_panel_api-3.6.2/tests/test_schema_one_undeclared_devices.py +166 -0
- span_panel_api-3.6.2/tests/test_snapshot_readiness_gate.py +544 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/.gitignore +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/LICENSE +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/README.md +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/_ssl.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/auth.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/models.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/conftest.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/panelbench_wire.json +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/reference_payloads/README.md +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/reference_payloads/bootstrap.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/reference_payloads/schema_one.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_adoption.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_ca_pinning.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_https_transport.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_leaf_name_mismatch.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_packaging.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_plaintext_warning.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_register_passphrase_unavailable.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_rest_transport_contract.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_fetch_transport_split.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_control_refusal.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_discovery.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_firmware.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_panel.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_snapshot.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_ssl_context.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_v2_status_parser.py +0 -0
- {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/tls_fixtures.py +0 -0
|
@@ -7,21 +7,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
7
7
|
Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the **last public release**, never against the beta before it. What one
|
|
8
8
|
beta corrected in an earlier beta does not appear at all: from the point of view of somebody upgrading between released versions, it never happened.
|
|
9
9
|
|
|
10
|
-
## [3.6.
|
|
10
|
+
## [3.6.2]
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
A reconnect waits for the panel's labels before handing out a snapshot, as connecting always has.
|
|
13
13
|
|
|
14
14
|
### Changed
|
|
15
15
|
|
|
16
|
-
- **`
|
|
16
|
+
- **`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.
|
|
17
|
+
- **`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.
|
|
18
|
+
- **The `schema-1` extra requires `span-panel-api-schema-1` 1.2.2 or newer**, the adapter that restarts its feed-link wait correctly.
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **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
|
|
23
|
+
feed links have arrived.
|
|
24
|
+
|
|
25
|
+
## [3.6.1]
|
|
19
26
|
|
|
20
27
|
The snapshot carries every PV inverter a panel commissions, and registration copes with a panel that cannot read its own passphrase.
|
|
21
28
|
|
|
22
29
|
### Added
|
|
23
30
|
|
|
24
|
-
- **`SpanPanelSnapshot.pv_inverters`** carries every commissioned PV inverter, keyed by its feeding circuit's id, which stays put when firmware r202639
|
|
31
|
+
- **`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
32
|
- **`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
33
|
- **`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
34
|
sets one.
|
|
@@ -29,10 +36,16 @@ The snapshot carries every PV inverter a panel commissions, and registration cop
|
|
|
29
36
|
|
|
30
37
|
### Changed
|
|
31
38
|
|
|
39
|
+
- **`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
|
|
40
|
+
met first.
|
|
41
|
+
- **`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
42
|
- **`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
43
|
- **`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
|
-
|
|
44
|
+
- **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`.
|
|
45
|
+
|
|
46
|
+
## [3.6.0] [YANKED]
|
|
47
|
+
|
|
48
|
+
Withdrawn from PyPI; 3.6.1 replaces it.
|
|
36
49
|
|
|
37
50
|
## [3.5.0]
|
|
38
51
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 3.6.
|
|
3
|
+
Version: 3.6.2
|
|
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
|
|
@@ -22,7 +22,7 @@ Requires-Dist: pyyaml>=6.0.0
|
|
|
22
22
|
Provides-Extra: schema-0
|
|
23
23
|
Requires-Dist: span-panel-api-schema-0>=1.2.0; extra == 'schema-0'
|
|
24
24
|
Provides-Extra: schema-1
|
|
25
|
-
Requires-Dist: span-panel-api-schema-1>=1.2.
|
|
25
|
+
Requires-Dist: span-panel-api-schema-1>=1.2.2; extra == 'schema-1'
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
28
28
|
# SPAN Panel API
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "span-panel-api"
|
|
3
|
-
version = "3.6.
|
|
3
|
+
version = "3.6.2"
|
|
4
4
|
description = "A client library for SPAN Panel API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "SpanPanel"}
|
|
@@ -63,8 +63,10 @@ dependencies = [
|
|
|
63
63
|
# the inverter identity fields 3.6.0 adds. Upgrading through the extra is what
|
|
64
64
|
# brings the adapters that do.
|
|
65
65
|
schema-0 = ["span-panel-api-schema-0>=1.2.0"]
|
|
66
|
-
# Raised to 1.2.
|
|
67
|
-
|
|
66
|
+
# Raised to 1.2.1 for 3.6.1: that adapter builds the `pv` that `SpanPanelSnapshot.pv` now documents.
|
|
67
|
+
# Raised to 1.2.2 for 3.6.2: the readiness gate every snapshot passes asks that
|
|
68
|
+
# 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"]
|
|
68
70
|
|
|
69
71
|
[project.urls]
|
|
70
72
|
Homepage = "https://github.com/SpanPanel/span-panel-api"
|
|
@@ -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.
|
|
@@ -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()
|
|
@@ -71,6 +71,9 @@ class _FakeAdapter:
|
|
|
71
71
|
def topics_to_subscribe(self) -> list[str]:
|
|
72
72
|
return [WILDCARD_TOPIC_FMT.format(serial="test-serial")]
|
|
73
73
|
|
|
74
|
+
def circuit_nodes_missing_names(self) -> list[str]:
|
|
75
|
+
return []
|
|
76
|
+
|
|
74
77
|
|
|
75
78
|
class TestRegisterConnectionCallback:
|
|
76
79
|
"""Callback subscription API — structural only (fan-out is tested in Task 4)."""
|
|
@@ -325,10 +328,22 @@ class TestGetSnapshotLiveness:
|
|
|
325
328
|
client = _make_client()
|
|
326
329
|
client._bridge = _FakeBridge(connected=True)
|
|
327
330
|
client._adapter = _FakeAdapter(ready=True, snapshot=sentinel)
|
|
331
|
+
# Ready and labelled: the readiness gate opens for it.
|
|
332
|
+
await client._settle(client._adapter)
|
|
328
333
|
|
|
329
334
|
snapshot = await client.get_snapshot()
|
|
330
335
|
assert snapshot is sentinel
|
|
331
336
|
|
|
337
|
+
async def test_raises_stale_while_labels_are_still_arriving(self) -> None:
|
|
338
|
+
"""A complete tree whose labels have not passed the readiness gate is not handed out."""
|
|
339
|
+
client = _make_client()
|
|
340
|
+
client._bridge = _FakeBridge(connected=True)
|
|
341
|
+
client._adapter = _FakeAdapter(ready=True, snapshot=_make_sentinel_snapshot())
|
|
342
|
+
|
|
343
|
+
with pytest.raises(SpanPanelStaleDataError) as exc_info:
|
|
344
|
+
await client.get_snapshot()
|
|
345
|
+
assert "labels" in str(exc_info.value).lower()
|
|
346
|
+
|
|
332
347
|
async def test_raised_exception_is_span_panel_error(self) -> None:
|
|
333
348
|
client = _make_client()
|
|
334
349
|
client._bridge = None
|
|
@@ -403,6 +418,8 @@ class TestStaleSnapshotDispatchGuard:
|
|
|
403
418
|
client = _make_client()
|
|
404
419
|
client._bridge = _FakeBridge(connected=True)
|
|
405
420
|
client._adapter = _FakeAdapter(ready=True, snapshot=snapshot_sentinel)
|
|
421
|
+
# Ready and labelled: the readiness gate opens for it.
|
|
422
|
+
await client._settle(client._adapter)
|
|
406
423
|
|
|
407
424
|
calls: list[SpanPanelSnapshot] = []
|
|
408
425
|
|
|
@@ -1146,6 +1146,9 @@ class TestSpanMqttClientSnapshot:
|
|
|
1146
1146
|
client._adapter.handle_message(f"{PREFIX}/$state", "ready")
|
|
1147
1147
|
client._adapter.handle_message(f"{PREFIX}/$description", _make_description(_core_description()))
|
|
1148
1148
|
client._adapter.handle_message(f"{PREFIX}/core/software-version", "test-fw")
|
|
1149
|
+
# Fed directly rather than through `_on_message`, so the readiness gate
|
|
1150
|
+
# is passed explicitly.
|
|
1151
|
+
await client._settle(client._adapter)
|
|
1149
1152
|
|
|
1150
1153
|
snapshot = await client.get_snapshot()
|
|
1151
1154
|
assert snapshot.serial_number == SERIAL
|
|
@@ -161,7 +161,7 @@ EXPECTED_PUBLIC_API = {
|
|
|
161
161
|
# Added 2026-10-02 (3.5.0): a 403 from a reduced-privilege token. A subclass
|
|
162
162
|
# of SpanPanelAuthError, so every existing except clause keeps its meaning.
|
|
163
163
|
"SpanPanelInsufficientPrivilegeError",
|
|
164
|
-
# Added 2026-10-06 (3.6.
|
|
164
|
+
# Added 2026-10-06 (3.6.1): registration reached a panel that cannot read its own
|
|
165
165
|
# passphrase. Additive, and a SpanPanelAPIError subclass rather than a
|
|
166
166
|
# SpanPanelAuthError, so no existing except clause starts telling a user
|
|
167
167
|
# their passphrase is wrong.
|
|
@@ -517,6 +517,46 @@ def test_the_feed_grace_starts_only_once_everything_else_has_arrived() -> None:
|
|
|
517
517
|
assert SOLAR_CIRCUIT in adapter.circuit_nodes_missing_names()
|
|
518
518
|
|
|
519
519
|
|
|
520
|
+
def test_the_feed_grace_restarts_once_every_inverter_has_been_placed() -> None:
|
|
521
|
+
"""A grace is spent on one wait, not carried into the next.
|
|
522
|
+
|
|
523
|
+
The first inverter's feed is waited on and arrives. Later the panel
|
|
524
|
+
commissions a second inverter whose feed has not landed yet; that wait gets
|
|
525
|
+
a full grace of its own rather than inheriting the first one's start time,
|
|
526
|
+
which has long since run out and would key the new inverter by its device id
|
|
527
|
+
the moment it appears.
|
|
528
|
+
"""
|
|
529
|
+
second_circuit = "5be1d2c3a4f5061728394a5b6c7d8e9f"
|
|
530
|
+
second_pv = "pv-2"
|
|
531
|
+
now = [100.0]
|
|
532
|
+
adapter = SchemaOneAdapter(PANEL, _schema(), clock=lambda: now[0])
|
|
533
|
+
_feed(adapter, omit=("connection/feeds-device-id",))
|
|
534
|
+
assert SOLAR_CIRCUIT in adapter.circuit_nodes_missing_names()
|
|
535
|
+
adapter.handle_message(
|
|
536
|
+
f"ebus/5/{SOLAR_CIRCUIT}/connection/feeds-device-id", _TREE[SOLAR_CIRCUIT]["connection/feeds-device-id"]
|
|
537
|
+
)
|
|
538
|
+
assert adapter.circuit_nodes_missing_names() == []
|
|
539
|
+
|
|
540
|
+
now[0] += FEED_GRACE_S + 1
|
|
541
|
+
description = json.loads(_TREE[PANEL]["$description"])
|
|
542
|
+
description["children"] = [*description["children"], second_circuit, second_pv]
|
|
543
|
+
tree = {
|
|
544
|
+
PANEL: {**_TREE[PANEL], "$description": json.dumps(description)},
|
|
545
|
+
second_pv: {**_TREE["pv"], "info/model": "SE7600H-B"},
|
|
546
|
+
second_circuit: {
|
|
547
|
+
topic: value for topic, value in _TREE[SOLAR_CIRCUIT].items() if topic != "connection/feeds-device-id"
|
|
548
|
+
}
|
|
549
|
+
| {"info/name": "Garage Solar", "info/spaces": "5,7"},
|
|
550
|
+
}
|
|
551
|
+
_feed(adapter, [PANEL, second_pv, second_circuit], tree=tree)
|
|
552
|
+
assert {inverter.device_id for inverter in adapter.build_snapshot().pv_inverters.values()} == {
|
|
553
|
+
"pv",
|
|
554
|
+
second_pv,
|
|
555
|
+
}, "precondition: the second inverter was commissioned"
|
|
556
|
+
|
|
557
|
+
assert second_circuit in adapter.circuit_nodes_missing_names()
|
|
558
|
+
|
|
559
|
+
|
|
520
560
|
def test_the_panel_firmware_version_is_waited_on_when_a_battery_is_declared() -> None:
|
|
521
561
|
"""The release build in the panel's firmware version decides the BESS meter's frame.
|
|
522
562
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""A device the panel no longer declares is not part of the panel, whatever it left behind.
|
|
2
|
+
|
|
3
|
+
Firmware r202639 renames a panel's inverters once it publishes more than one: the
|
|
4
|
+
single inverter's `<panel>-se7600h-us` becomes `<panel>-se7600h-us-<n>`, one per
|
|
5
|
+
inverter. The upgrade does not clear the old device's retained topics, so the
|
|
6
|
+
broker keeps replaying a `$description` that still names the panel as its root
|
|
7
|
+
and parent, beside the new devices, until something removes it.
|
|
8
|
+
|
|
9
|
+
Membership comes from the parent's `$description.children` alone. The SDK
|
|
10
|
+
subscribes to a child only once its parent declares it (and drops one the parent
|
|
11
|
+
stops declaring), and `ControllerRoutes` holds a message no route has asked for
|
|
12
|
+
rather than delivering it. A stale device's own claim to a parent is never read.
|
|
13
|
+
These tests pin that through the adapter's real `handle_message` path, so an
|
|
14
|
+
`ebus-sdk` upgrade that changed how the tree is walked would fail here rather than
|
|
15
|
+
surface as a ghost inverter.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
|
|
24
|
+
from reference_payloads.schema_one import RetainedTopicTree, parent_child_tree
|
|
25
|
+
from span_panel_api.models import V2HomieSchema
|
|
26
|
+
from span_panel_api_schema_1 import SchemaOneAdapter
|
|
27
|
+
|
|
28
|
+
PANEL = "example-40t-001"
|
|
29
|
+
SOLAR_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4"
|
|
30
|
+
SECOND_SOLAR_CIRCUIT = "5be1d2c3a4f5061728394a5b6c7d8e9f"
|
|
31
|
+
|
|
32
|
+
# Illustrative ids in the shape firmware r202639 uses.
|
|
33
|
+
STALE_PV = f"{PANEL}-se7600h-us"
|
|
34
|
+
FIRST_PV = f"{PANEL}-se7600h-us-1"
|
|
35
|
+
SECOND_PV = f"{PANEL}-se7600h-us-2"
|
|
36
|
+
STALE_MODEL = "SE7600H-STALE"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _schema() -> V2HomieSchema:
|
|
40
|
+
return V2HomieSchema(
|
|
41
|
+
firmware_version="spanos2/r202639/01",
|
|
42
|
+
types_schema_hash="sha256:test",
|
|
43
|
+
types={},
|
|
44
|
+
data_model_version="1.0",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _with_children(tree: dict[str, dict[str, str]], children: list[str]) -> None:
|
|
49
|
+
description = json.loads(tree[PANEL]["$description"])
|
|
50
|
+
description["children"] = children
|
|
51
|
+
tree[PANEL] = {**tree[PANEL], "$description": json.dumps(description)}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _upgraded_tree(stale_state: str | None) -> dict[str, dict[str, str]]:
|
|
55
|
+
"""Two declared inverters, each fed by its own circuit; and, unless `stale_state` is None,
|
|
56
|
+
the old inverter's retained topics, which the panel no longer declares."""
|
|
57
|
+
tree = {device_id: dict(topics) for device_id, topics in parent_child_tree().items()}
|
|
58
|
+
pv = tree.pop("pv")
|
|
59
|
+
declared = [child for child in json.loads(tree[PANEL]["$description"])["children"] if child != "pv"]
|
|
60
|
+
_with_children(tree, [*declared, SECOND_SOLAR_CIRCUIT, FIRST_PV, SECOND_PV])
|
|
61
|
+
tree[FIRST_PV] = dict(pv)
|
|
62
|
+
tree[SECOND_PV] = {**pv, "info/model": "SE7600H-B"}
|
|
63
|
+
tree[SOLAR_CIRCUIT]["connection/feeds-device-id"] = FIRST_PV
|
|
64
|
+
tree[SECOND_SOLAR_CIRCUIT] = {
|
|
65
|
+
**tree[SOLAR_CIRCUIT],
|
|
66
|
+
"connection/feeds-device-id": SECOND_PV,
|
|
67
|
+
"info/name": "Garage Solar",
|
|
68
|
+
"info/spaces": "5,7",
|
|
69
|
+
}
|
|
70
|
+
if stale_state is not None:
|
|
71
|
+
tree[STALE_PV] = {**pv, "$state": stale_state, "info/model": STALE_MODEL}
|
|
72
|
+
return tree
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _replay(adapter: SchemaOneAdapter, tree: RetainedTopicTree, order: list[str]) -> None:
|
|
76
|
+
"""Deliver each device's retained topics as the broker would, in `order`."""
|
|
77
|
+
for device_id in order:
|
|
78
|
+
topics = tree[device_id]
|
|
79
|
+
prefix = f"ebus/5/{device_id}"
|
|
80
|
+
adapter.handle_message(f"{prefix}/$description", topics["$description"])
|
|
81
|
+
adapter.handle_message(f"{prefix}/$state", topics["$state"])
|
|
82
|
+
for topic, value in topics.items():
|
|
83
|
+
if not topic.startswith("$"):
|
|
84
|
+
adapter.handle_message(f"{prefix}/{topic}", value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _without_stale() -> SchemaOneAdapter:
|
|
88
|
+
tree = _upgraded_tree(None)
|
|
89
|
+
adapter = SchemaOneAdapter(PANEL, _schema())
|
|
90
|
+
_replay(adapter, tree, [PANEL, *[device_id for device_id in tree if device_id != PANEL]])
|
|
91
|
+
return adapter
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _assert_stale_pv_is_absent(adapter: SchemaOneAdapter) -> None:
|
|
95
|
+
"""Nothing the adapter reports differs from a broker that never held the stale device."""
|
|
96
|
+
reference = _without_stale()
|
|
97
|
+
snapshot = adapter.build_snapshot()
|
|
98
|
+
|
|
99
|
+
assert adapter.is_ready()
|
|
100
|
+
assert snapshot == reference.build_snapshot()
|
|
101
|
+
assert adapter.build_field_metadata() == reference.build_field_metadata()
|
|
102
|
+
# Spelled out as well, so a failure names what leaked.
|
|
103
|
+
assert {key: inverter.device_id for key, inverter in snapshot.pv_inverters.items()} == {
|
|
104
|
+
SOLAR_CIRCUIT: FIRST_PV,
|
|
105
|
+
SECOND_SOLAR_CIRCUIT: SECOND_PV,
|
|
106
|
+
}
|
|
107
|
+
assert snapshot.pv.model is None
|
|
108
|
+
assert all(device.device_id != STALE_PV for device in snapshot.adopted_devices)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.parametrize("stale_state", ["ready", "init", "disconnected", "lost"])
|
|
112
|
+
@pytest.mark.parametrize("stale_first", [True, False], ids=["stale-replayed-first", "stale-replayed-last"])
|
|
113
|
+
def test_a_pv_device_the_panel_no_longer_declares_never_reaches_the_snapshot(stale_state: str, stale_first: bool) -> None:
|
|
114
|
+
"""Whatever its retained `$state` -- `ready` included -- and whenever the broker replays it."""
|
|
115
|
+
tree = _upgraded_tree(stale_state)
|
|
116
|
+
others = [device_id for device_id in tree if device_id not in (PANEL, STALE_PV)]
|
|
117
|
+
adapter = SchemaOneAdapter(PANEL, _schema())
|
|
118
|
+
|
|
119
|
+
_replay(adapter, tree, [STALE_PV, PANEL, *others] if stale_first else [PANEL, *others, STALE_PV])
|
|
120
|
+
|
|
121
|
+
_assert_stale_pv_is_absent(adapter)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _before_the_upgrade() -> dict[str, dict[str, str]]:
|
|
125
|
+
"""The panel before r202639: one inverter, declared, under the id the upgrade retires."""
|
|
126
|
+
tree = {device_id: dict(topics) for device_id, topics in parent_child_tree().items()}
|
|
127
|
+
tree[STALE_PV] = {**tree.pop("pv"), "info/model": STALE_MODEL}
|
|
128
|
+
declared = json.loads(tree[PANEL]["$description"])["children"]
|
|
129
|
+
_with_children(tree, [STALE_PV if child == "pv" else child for child in declared])
|
|
130
|
+
tree[SOLAR_CIRCUIT]["connection/feeds-device-id"] = STALE_PV
|
|
131
|
+
return tree
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_a_declared_pv_device_does_reach_the_snapshot() -> None:
|
|
135
|
+
"""The control: the same device, declared, is read -- so the tests above can see a leak."""
|
|
136
|
+
tree = _before_the_upgrade()
|
|
137
|
+
adapter = SchemaOneAdapter(PANEL, _schema())
|
|
138
|
+
|
|
139
|
+
_replay(adapter, tree, [PANEL, *[device_id for device_id in tree if device_id != PANEL]])
|
|
140
|
+
|
|
141
|
+
snapshot = adapter.build_snapshot()
|
|
142
|
+
assert {key: inverter.device_id for key, inverter in snapshot.pv_inverters.items()} == {SOLAR_CIRCUIT: STALE_PV}
|
|
143
|
+
assert snapshot.pv.model == STALE_MODEL
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.parametrize("republished", ["through-init", "while-ready"])
|
|
147
|
+
def test_a_pv_device_the_panel_stops_declaring_mid_session_leaves_the_snapshot(republished: str) -> None:
|
|
148
|
+
"""An adapter running across the upgrade drops the inverter the new `$description` no longer names.
|
|
149
|
+
|
|
150
|
+
The panel either passes through `init` before announcing its new tree, or
|
|
151
|
+
republishes its `$description` while staying `ready`; both must drop it.
|
|
152
|
+
"""
|
|
153
|
+
before = _before_the_upgrade()
|
|
154
|
+
adapter = SchemaOneAdapter(PANEL, _schema())
|
|
155
|
+
_replay(adapter, before, [PANEL, *[device_id for device_id in before if device_id != PANEL]])
|
|
156
|
+
after = _upgraded_tree("ready")
|
|
157
|
+
|
|
158
|
+
if republished == "through-init":
|
|
159
|
+
adapter.handle_message(f"ebus/5/{PANEL}/$state", "init")
|
|
160
|
+
adapter.handle_message(f"ebus/5/{PANEL}/$description", after[PANEL]["$description"])
|
|
161
|
+
if republished == "through-init":
|
|
162
|
+
adapter.handle_message(f"ebus/5/{PANEL}/$state", "ready")
|
|
163
|
+
# Then everything else retained, the stale inverter included.
|
|
164
|
+
_replay(adapter, after, [device_id for device_id in after if device_id != PANEL])
|
|
165
|
+
|
|
166
|
+
_assert_stale_pv_is_absent(adapter)
|