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.
Files changed (107) hide show
  1. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/CHANGELOG.md +20 -7
  2. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/PKG-INFO +2 -2
  3. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/pyproject.toml +5 -3
  4. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/__init__.py +1 -1
  5. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/mqtt/client.py +146 -29
  6. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_mqtt_client_connection.py +17 -0
  7. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_mqtt_homie.py +3 -0
  8. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_public_api_unchanged.py +1 -1
  9. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_adapter.py +40 -0
  10. span_panel_api-3.6.2/tests/test_schema_one_undeclared_devices.py +166 -0
  11. span_panel_api-3.6.2/tests/test_snapshot_readiness_gate.py +544 -0
  12. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/.gitignore +0 -0
  13. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/LICENSE +0 -0
  14. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/README.md +0 -0
  15. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/_http.py +0 -0
  16. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/_ssl.py +0 -0
  17. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/adapters.py +0 -0
  18. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/auth.py +0 -0
  19. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/const.py +0 -0
  20. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/detection.py +0 -0
  21. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/dispatch.py +0 -0
  22. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/exceptions.py +0 -0
  23. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/factory.py +0 -0
  24. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/models.py +0 -0
  25. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/mqtt/__init__.py +0 -0
  26. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/mqtt/async_client.py +0 -0
  27. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/mqtt/connection.py +0 -0
  28. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/mqtt/const.py +0 -0
  29. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/mqtt/control.py +0 -0
  30. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/mqtt/models.py +0 -0
  31. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/phase_validation.py +0 -0
  32. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/protocol.py +0 -0
  33. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/py.typed +0 -0
  34. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/src/span_panel_api/schema_drift.py +0 -0
  35. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/conftest.py +0 -0
  36. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  37. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  38. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  39. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/flat_wire.json +0 -0
  40. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  41. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/panelbench_wire.json +0 -0
  42. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/v2/README.md +0 -0
  43. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/fixtures/v2/status.json +0 -0
  44. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/reference_payloads/README.md +0 -0
  45. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/reference_payloads/__init__.py +0 -0
  46. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/reference_payloads/bootstrap.py +0 -0
  47. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/reference_payloads/schema_one.py +0 -0
  48. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/simulation_fixtures/circuits.response.txt +0 -0
  49. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/simulation_fixtures/panel.response.txt +0 -0
  50. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/simulation_fixtures/soe.response.txt +0 -0
  51. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/simulation_fixtures/status.response.txt +0 -0
  52. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_absent_readings_are_not_zero.py +0 -0
  53. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_accumulator.py +0 -0
  54. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_adapters_discovery.py +0 -0
  55. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_adopted_control.py +0 -0
  56. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_adoption.py +0 -0
  57. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_async_mqtt_client.py +0 -0
  58. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_auth_and_homie_helpers.py +0 -0
  59. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_auth_redaction.py +0 -0
  60. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_ca_pinning.py +0 -0
  61. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_catalog_divergence.py +0 -0
  62. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_control_interceptor.py +0 -0
  63. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_detection_auth.py +0 -0
  64. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_exceptions.py +0 -0
  65. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_factory_dispatch.py +0 -0
  66. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_field_metadata.py +0 -0
  67. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_https_transport.py +0 -0
  68. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_leaf_name_mismatch.py +0 -0
  69. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_live_flat_differential.py +0 -0
  70. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_mqtt_bridge.py +0 -0
  71. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_mqtt_connect_flow.py +0 -0
  72. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_mqtt_debounce.py +0 -0
  73. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_packaging.py +0 -0
  74. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_phase_validation_configs.py +0 -0
  75. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_phase_validation_errors.py +0 -0
  76. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_plaintext_warning.py +0 -0
  77. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_protocol_conformance.py +0 -0
  78. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_protocol_models.py +0 -0
  79. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_publish_outcome.py +0 -0
  80. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_redispatch_on_reconnect.py +0 -0
  81. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_reference_tree_values.py +0 -0
  82. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_register_passphrase_unavailable.py +0 -0
  83. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_rest_transport_contract.py +0 -0
  84. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_fetch_transport_split.py +0 -0
  85. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_generation_cross_check.py +0 -0
  86. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_migration_delta.py +0 -0
  87. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_charge_limit.py +0 -0
  88. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_circuits.py +0 -0
  89. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_conformance.py +0 -0
  90. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_connection_health.py +0 -0
  91. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_control_refusal.py +0 -0
  92. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_devices.py +0 -0
  93. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_discovery.py +0 -0
  94. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_extension.py +0 -0
  95. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_firmware.py +0 -0
  96. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_panel.py +0 -0
  97. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_pcs.py +0 -0
  98. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_service_entrance.py +0 -0
  99. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_shed_forecast.py +0 -0
  100. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_snapshot.py +0 -0
  101. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_one_transport.py +0 -0
  102. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_provenance.py +0 -0
  103. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_schema_zero_adapter.py +0 -0
  104. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_shared_http_client.py +0 -0
  105. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_ssl_context.py +0 -0
  106. {span_panel_api-3.6.1b1 → span_panel_api-3.6.2}/tests/test_v2_status_parser.py +0 -0
  107. {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.1b1]
10
+ ## [3.6.2]
11
11
 
12
- 3.6.0 was withdrawn from PyPI; this release carries its changes, listed under 3.6.0 below, except how `pv` is chosen, which this release replaces.
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
- - **`SpanPanelSnapshot.pv` is documented as the lone inverter or the inverters together**, and the `schema-1` extra requires `span-panel-api-schema-1` 1.2.1 or newer.
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
- ## [3.6.0] [YANKED]
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 renames a panel's inverters, or by its device id where no circuit feeds it.
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
- - **`SpanPanelSnapshot.pv` is the inverter on the lowest breaker space when more than one is commissioned**, rather than whichever one the adapter met first.
35
- - **`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.
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.1b1
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.1b1; extra == 'schema-1'
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.1b1"
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.1b1 for 3.6.1b1: that adapter stops ranking one inverter as pv.
67
- schema-1 = ["span-panel-api-schema-1>=1.2.1b1"]
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.0): registration reached a panel that cannot read its own
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 circuit names...")
526
+ _LOGGER.debug("MQTT: Homie device ready, waiting for its labels...")
517
527
 
518
- # Wait for circuit name properties to arrive (retained messages
519
- # may arrive after $state=ready). Without this, the first snapshot
520
- # has empty circuit names and entities are created without labels.
521
- await self._wait_for_circuit_names(timeout=_CIRCUIT_NAMES_TIMEOUT_S)
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() and self._ready_event is not None:
1374
- self._ready_event.set()
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 adapter.is_ready() and self._loop is not None:
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
- async def _wait_for_circuit_names(self, timeout: float) -> None:
1723
- """Wait for all circuit-like nodes to have a ``name`` property.
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
- Retained MQTT messages may arrive after the Homie device transitions
1726
- to ready. This polls the schema adapter at short intervals and
1727
- returns as soon as all circuit names are populated, or when the
1728
- timeout elapses (non-fatal — entities will use fallback names).
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
- while time.monotonic() < deadline:
1733
- missing = adapter.circuit_nodes_missing_names()
1734
- if not missing:
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
- still_missing = adapter.circuit_nodes_missing_names()
1740
- if still_missing:
1741
- _LOGGER.warning(
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(still_missing),
1744
- still_missing[:5],
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 Homie device is not ready, no
1787
- dispatch occurs. This prevents a pending debounce timer that was
1788
- scheduled just before a disconnect from delivering a stale
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 adapter.is_ready():
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.0): registration reached a panel that cannot read its own
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)