span-panel-api 3.6.0__tar.gz → 3.6.1__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 (106) hide show
  1. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/CHANGELOG.md +10 -4
  2. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/PKG-INFO +22 -18
  3. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/README.md +20 -16
  4. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/pyproject.toml +3 -2
  5. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/__init__.py +1 -1
  6. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/models.py +14 -8
  7. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_public_api_unchanged.py +1 -1
  8. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_snapshot.py +74 -21
  9. span_panel_api-3.6.1/tests/test_schema_one_undeclared_devices.py +166 -0
  10. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/.gitignore +0 -0
  11. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/LICENSE +0 -0
  12. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/_http.py +0 -0
  13. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/_ssl.py +0 -0
  14. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/adapters.py +0 -0
  15. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/auth.py +0 -0
  16. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/const.py +0 -0
  17. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/detection.py +0 -0
  18. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/dispatch.py +0 -0
  19. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/exceptions.py +0 -0
  20. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/factory.py +0 -0
  21. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/__init__.py +0 -0
  22. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/async_client.py +0 -0
  23. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/client.py +0 -0
  24. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/connection.py +0 -0
  25. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/const.py +0 -0
  26. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/control.py +0 -0
  27. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/models.py +0 -0
  28. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/phase_validation.py +0 -0
  29. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/protocol.py +0 -0
  30. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/py.typed +0 -0
  31. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/schema_drift.py +0 -0
  32. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/conftest.py +0 -0
  33. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  34. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  35. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  36. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/flat_wire.json +0 -0
  37. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  38. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/panelbench_wire.json +0 -0
  39. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/v2/README.md +0 -0
  40. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/v2/status.json +0 -0
  41. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/reference_payloads/README.md +0 -0
  42. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/reference_payloads/__init__.py +0 -0
  43. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/reference_payloads/bootstrap.py +0 -0
  44. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/reference_payloads/schema_one.py +0 -0
  45. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/simulation_fixtures/circuits.response.txt +0 -0
  46. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/simulation_fixtures/panel.response.txt +0 -0
  47. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/simulation_fixtures/soe.response.txt +0 -0
  48. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/simulation_fixtures/status.response.txt +0 -0
  49. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_absent_readings_are_not_zero.py +0 -0
  50. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_accumulator.py +0 -0
  51. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_adapters_discovery.py +0 -0
  52. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_adopted_control.py +0 -0
  53. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_adoption.py +0 -0
  54. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_async_mqtt_client.py +0 -0
  55. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_auth_and_homie_helpers.py +0 -0
  56. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_auth_redaction.py +0 -0
  57. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_ca_pinning.py +0 -0
  58. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_catalog_divergence.py +0 -0
  59. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_control_interceptor.py +0 -0
  60. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_detection_auth.py +0 -0
  61. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_exceptions.py +0 -0
  62. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_factory_dispatch.py +0 -0
  63. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_field_metadata.py +0 -0
  64. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_https_transport.py +0 -0
  65. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_leaf_name_mismatch.py +0 -0
  66. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_live_flat_differential.py +0 -0
  67. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_mqtt_bridge.py +0 -0
  68. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_mqtt_client_connection.py +0 -0
  69. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_mqtt_connect_flow.py +0 -0
  70. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_mqtt_debounce.py +0 -0
  71. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_mqtt_homie.py +0 -0
  72. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_packaging.py +0 -0
  73. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_phase_validation_configs.py +0 -0
  74. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_phase_validation_errors.py +0 -0
  75. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_plaintext_warning.py +0 -0
  76. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_protocol_conformance.py +0 -0
  77. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_protocol_models.py +0 -0
  78. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_publish_outcome.py +0 -0
  79. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_redispatch_on_reconnect.py +0 -0
  80. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_reference_tree_values.py +0 -0
  81. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_register_passphrase_unavailable.py +0 -0
  82. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_rest_transport_contract.py +0 -0
  83. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_fetch_transport_split.py +0 -0
  84. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_generation_cross_check.py +0 -0
  85. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_migration_delta.py +0 -0
  86. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_adapter.py +0 -0
  87. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_charge_limit.py +0 -0
  88. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_circuits.py +0 -0
  89. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_conformance.py +0 -0
  90. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_connection_health.py +0 -0
  91. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_control_refusal.py +0 -0
  92. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_devices.py +0 -0
  93. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_discovery.py +0 -0
  94. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_extension.py +0 -0
  95. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_firmware.py +0 -0
  96. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_panel.py +0 -0
  97. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_pcs.py +0 -0
  98. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_service_entrance.py +0 -0
  99. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_shed_forecast.py +0 -0
  100. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_transport.py +0 -0
  101. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_provenance.py +0 -0
  102. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_zero_adapter.py +0 -0
  103. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_shared_http_client.py +0 -0
  104. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_ssl_context.py +0 -0
  105. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_v2_status_parser.py +0 -0
  106. {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/tls_fixtures.py +0 -0
@@ -7,13 +7,13 @@ 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.0]
10
+ ## [3.6.1]
11
11
 
12
12
  The snapshot carries every PV inverter a panel commissions, and registration copes with a panel that cannot read its own passphrase.
13
13
 
14
14
  ### Added
15
15
 
16
- - **`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.
16
+ - **`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.
17
17
  - **`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`.
18
18
  - **`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
19
19
  sets one.
@@ -21,10 +21,16 @@ The snapshot carries every PV inverter a panel commissions, and registration cop
21
21
 
22
22
  ### Changed
23
23
 
24
+ - **`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
25
+ met first.
26
+ - **`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.
24
27
  - **`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.
25
28
  - **`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`.
26
- - **`SpanPanelSnapshot.pv` is the inverter on the lowest breaker space when more than one is commissioned**, rather than whichever one the adapter met first.
27
- - **`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.
29
+ - **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`.
30
+
31
+ ## [3.6.0] [YANKED]
32
+
33
+ Withdrawn from PyPI; 3.6.1 replaces it.
28
34
 
29
35
  ## [3.5.0]
30
36
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 3.6.0
3
+ Version: 3.6.1
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.0; extra == 'schema-1'
25
+ Requires-Dist: span-panel-api-schema-1>=1.2.1; extra == 'schema-1'
26
26
  Description-Content-Type: text/markdown
27
27
 
28
28
  # SPAN Panel API
@@ -175,21 +175,25 @@ transport-specific classes.
175
175
 
176
176
  All panel state is represented as immutable, frozen dataclasses:
177
177
 
178
- | Dataclass | Content |
179
- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
180
- | `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, shed forecast, circuits, battery, PV, EVSE, MID |
181
- | `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current, `$target` pending state |
182
- | `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh, own meter reading, communication state, link health, `model` / `part_number`, nameplate capacity |
183
- | `SpanPVSnapshot` | PV inverter: link health, `model` / `part_number`, nameplate capacity |
184
- | `SpanEvseSnapshot` | EVSE (EV charger): status, lock state, advertised current, link health, `model` / `part_number` / serial / version metadata |
185
- | `SpanMidSnapshot` | Microgrid Interconnect Device: islanding state, grid state, grid-forming entity |
186
- | `AdoptedDevice` | A device type this library models nothing for, carried whole: identity, readings, proxy link |
187
- | `ExtensionProperty` | A vendor property on a device this library _does_ model, with its value and the subject it hangs off |
188
-
189
- Identity is normalised across every DER class: **`model` is the human designation and `part_number` is the SKU**, on `battery`, `evse` and `pv` alike. `product_name` was retired in 3.0.0 — see the changelog, because `battery.model` changes value for
190
- existing flat users at that upgrade.
191
-
192
- `mid`, `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.
178
+ - **`SpanPanelSnapshot`**: complete panel state. Identity (serial, firmware, vendor, model, hardware version), panel size, main breaker rating, main relay, door and proximity state, uptime, network links with the Wi-Fi SSID and vendor cloud, grid and
179
+ feedthrough power and energy, grid/DSM state, run configuration, dominant power source, grid islandability, per-leg voltages, power flows, lugs current and whether the lugs are the service entrance, shed policy and forecast. It holds `circuits`,
180
+ `battery`, `pv`, `pv_inverters`, `evse`, `mid`, `pcs`, `adopted_devices` and `extension_properties`.
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
+ position, PCS management and priority, and the `$target` pending state for relay and priority.
183
+ - **`SpanBatterySnapshot`**: the BESS. SoE percentage and kWh, its own meter reading, communication state, link health, vendor / `model` / `part_number` / serial / version, nameplate capacity.
184
+ - **`SpanPVSnapshot`**: one PV inverter. Link health, vendor / `model` / serial / version, nameplate capacity (the array's DC size), feeding circuit, relative position, its wire `device_id` and its `node_id`, which is its key in `pv_inverters`.
185
+ `SpanPanelSnapshot.pv` is the lone inverter, or describes several together.
186
+ - **`SpanEvseSnapshot`**: one EV charger. `node_id`, feeding circuit, status, lock state, advertised current, link health, vendor / `model` / `part_number` / serial / version, charge-current limit, ceiling, pending target and settability, and the effective
187
+ limit it applies.
188
+ - **`SpanMidSnapshot`**: the Microgrid Interconnect Device. `node_id`, islanding state, grid state, grid-forming entity and its device name, vendor / `model` / serial / software and hardware version.
189
+ - **`SpanPcsSnapshot`**: the Power Control System. Enabled and active, the enforced import limit and its binding constraint, and each import constraint's limit, enablement and active state (feed, operator, off-grid, requested).
190
+ - **`AdoptedDevice`**: a device type this library models nothing for, carried whole. Identity (id, type, name, vendor, model, serial, versions), its declared parent, whether a peer proxies it, and its readings.
191
+ - **`ExtensionProperty`**: a vendor property on a device this library _does_ model. The subject it hangs off, its node and property ids and its `path`, datatype / unit / format, settability, value, and whether its node has curated siblings.
192
+
193
+ Identity is normalised across every DER class: **`model` is the human designation**, on `battery`, `evse` and `pv` alike, and **`part_number` is the SKU**, on `battery` and `evse`; `SpanPVSnapshot` has no SKU field. `product_name` was retired in 3.0.0 —
194
+ see the changelog, because `battery.model` changes value for existing flat users at that upgrade.
195
+
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.
193
197
 
194
198
  ## Usage
195
199
 
@@ -611,7 +615,7 @@ src/span_panel_api/ # distribution: span-panel-api (no parser)
611
615
  ├── dispatch.py # select_adapter_key() — what does this panel need?
612
616
  ├── exceptions.py # Exception hierarchy
613
617
  ├── factory.py # create_span_client() → SpanMqttClient
614
- ├── models.py # Snapshot dataclasses (panel, circuit, battery, PV, EVSE, MID, adopted)
618
+ ├── models.py # Snapshot dataclasses (panel, circuit, battery, PV, EVSE, MID, PCS, adopted, extension)
615
619
  ├── phase_validation.py # Electrical phase utilities
616
620
  ├── protocol.py # PEP 544 protocols, SchemaAdapter, PanelCapability flags
617
621
  ├── schema_drift.py # Reporting a panel that outruns what we can read
@@ -148,21 +148,25 @@ transport-specific classes.
148
148
 
149
149
  All panel state is represented as immutable, frozen dataclasses:
150
150
 
151
- | Dataclass | Content |
152
- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
153
- | `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, shed forecast, circuits, battery, PV, EVSE, MID |
154
- | `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current, `$target` pending state |
155
- | `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh, own meter reading, communication state, link health, `model` / `part_number`, nameplate capacity |
156
- | `SpanPVSnapshot` | PV inverter: link health, `model` / `part_number`, nameplate capacity |
157
- | `SpanEvseSnapshot` | EVSE (EV charger): status, lock state, advertised current, link health, `model` / `part_number` / serial / version metadata |
158
- | `SpanMidSnapshot` | Microgrid Interconnect Device: islanding state, grid state, grid-forming entity |
159
- | `AdoptedDevice` | A device type this library models nothing for, carried whole: identity, readings, proxy link |
160
- | `ExtensionProperty` | A vendor property on a device this library _does_ model, with its value and the subject it hangs off |
161
-
162
- Identity is normalised across every DER class: **`model` is the human designation and `part_number` is the SKU**, on `battery`, `evse` and `pv` alike. `product_name` was retired in 3.0.0 — see the changelog, because `battery.model` changes value for
163
- existing flat users at that upgrade.
164
-
165
- `mid`, `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.
151
+ - **`SpanPanelSnapshot`**: complete panel state. Identity (serial, firmware, vendor, model, hardware version), panel size, main breaker rating, main relay, door and proximity state, uptime, network links with the Wi-Fi SSID and vendor cloud, grid and
152
+ feedthrough power and energy, grid/DSM state, run configuration, dominant power source, grid islandability, per-leg voltages, power flows, lugs current and whether the lugs are the service entrance, shed policy and forecast. It holds `circuits`,
153
+ `battery`, `pv`, `pv_inverters`, `evse`, `mid`, `pcs`, `adopted_devices` and `extension_properties`.
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
+ position, PCS management and priority, and the `$target` pending state for relay and priority.
156
+ - **`SpanBatterySnapshot`**: the BESS. SoE percentage and kWh, its own meter reading, communication state, link health, vendor / `model` / `part_number` / serial / version, nameplate capacity.
157
+ - **`SpanPVSnapshot`**: one PV inverter. Link health, vendor / `model` / serial / version, nameplate capacity (the array's DC size), feeding circuit, relative position, its wire `device_id` and its `node_id`, which is its key in `pv_inverters`.
158
+ `SpanPanelSnapshot.pv` is the lone inverter, or describes several together.
159
+ - **`SpanEvseSnapshot`**: one EV charger. `node_id`, feeding circuit, status, lock state, advertised current, link health, vendor / `model` / `part_number` / serial / version, charge-current limit, ceiling, pending target and settability, and the effective
160
+ limit it applies.
161
+ - **`SpanMidSnapshot`**: the Microgrid Interconnect Device. `node_id`, islanding state, grid state, grid-forming entity and its device name, vendor / `model` / serial / software and hardware version.
162
+ - **`SpanPcsSnapshot`**: the Power Control System. Enabled and active, the enforced import limit and its binding constraint, and each import constraint's limit, enablement and active state (feed, operator, off-grid, requested).
163
+ - **`AdoptedDevice`**: a device type this library models nothing for, carried whole. Identity (id, type, name, vendor, model, serial, versions), its declared parent, whether a peer proxies it, and its readings.
164
+ - **`ExtensionProperty`**: a vendor property on a device this library _does_ model. The subject it hangs off, its node and property ids and its `path`, datatype / unit / format, settability, value, and whether its node has curated siblings.
165
+
166
+ Identity is normalised across every DER class: **`model` is the human designation**, on `battery`, `evse` and `pv` alike, and **`part_number` is the SKU**, on `battery` and `evse`; `SpanPVSnapshot` has no SKU field. `product_name` was retired in 3.0.0 —
167
+ see the changelog, because `battery.model` changes value for existing flat users at that upgrade.
168
+
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.
166
170
 
167
171
  ## Usage
168
172
 
@@ -584,7 +588,7 @@ src/span_panel_api/ # distribution: span-panel-api (no parser)
584
588
  ├── dispatch.py # select_adapter_key() — what does this panel need?
585
589
  ├── exceptions.py # Exception hierarchy
586
590
  ├── factory.py # create_span_client() → SpanMqttClient
587
- ├── models.py # Snapshot dataclasses (panel, circuit, battery, PV, EVSE, MID, adopted)
591
+ ├── models.py # Snapshot dataclasses (panel, circuit, battery, PV, EVSE, MID, PCS, adopted, extension)
588
592
  ├── phase_validation.py # Electrical phase utilities
589
593
  ├── protocol.py # PEP 544 protocols, SchemaAdapter, PanelCapability flags
590
594
  ├── schema_drift.py # Reporting a panel that outruns what we can read
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "3.6.0"
3
+ version = "3.6.1"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -63,7 +63,8 @@ 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
- schema-1 = ["span-panel-api-schema-1>=1.2.0"]
66
+ # Raised to 1.2.1 for 3.6.1: that adapter builds the `pv` that `SpanPanelSnapshot.pv` now documents.
67
+ schema-1 = ["span-panel-api-schema-1>=1.2.1"]
67
68
 
68
69
  [project.urls]
69
70
  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.
@@ -78,7 +78,8 @@ class SpanPVSnapshot:
78
78
 
79
79
  A panel may commission more than one inverter, and from firmware r202639 each
80
80
  is published as its own device. `SpanPanelSnapshot.pv_inverters` carries all
81
- of them; `SpanPanelSnapshot.pv` carries one, chosen as that field documents.
81
+ of them; `SpanPanelSnapshot.pv` is the lone inverter, or describes several
82
+ together, as that field documents.
82
83
  """
83
84
 
84
85
  vendor_name: str | None = None # pv/vendor-name
@@ -117,6 +118,10 @@ class SpanPVSnapshot:
117
118
  it does not know) and is deliberately distinct from `False`. The enum has
118
119
  three members, `OK,LOST,DEGRADED`, and no UNKNOWN, so absence is the only
119
120
  way to say it.
121
+
122
+ On a `pv` that describes several inverters together, `None` also means no
123
+ link is reported down and not every one is reported up, so it is never
124
+ `True` while any inverter's link is unreported; see `SpanPanelSnapshot.pv`.
120
125
  """
121
126
 
122
127
  serial_number: str | None = None
@@ -141,7 +146,8 @@ class SpanPVSnapshot:
141
146
  Named after `SpanEvseSnapshot.node_id`, which plays the same role for
142
147
  chargers. It is the feeding circuit's id when a circuit feeds the inverter,
143
148
  because that id stays put when the inverter's own device id changes, and
144
- `device_id` otherwise.
149
+ `device_id` otherwise. `None` also on a `pv` that describes several
150
+ inverters together.
145
151
  """
146
152
 
147
153
 
@@ -1182,13 +1188,13 @@ class SpanPanelSnapshot:
1182
1188
  circuits: dict[str, SpanCircuitSnapshot] = field(default_factory=dict)
1183
1189
  battery: SpanBatterySnapshot = field(default_factory=SpanBatterySnapshot)
1184
1190
  pv: SpanPVSnapshot = field(default_factory=SpanPVSnapshot)
1185
- """One PV inverter, or the empty snapshot when none is commissioned.
1191
+ """The lone PV inverter, or the inverters together; the empty snapshot when none is commissioned.
1186
1192
 
1187
- With a single inverter it is that inverter. With several it is the one whose
1188
- feeding circuit occupies the lowest breaker space; an inverter with no
1189
- feeding circuit ranks after every circuit-fed one, and remaining ties go to
1190
- the lowest device id. Kept for consumers written before `pv_inverters`,
1191
- which carries every inverter, this one included.
1193
+ With one inverter it is that inverter, identical to its `pv_inverters` entry. With several it describes them together
1194
+ and identifies none of them: vendor and model where every inverter shares one, the sum of their installed DC sizes where
1195
+ every inverter publishes one, and their link down if any is reported down, up if every one is reported up, unknown
1196
+ otherwise. It never carries an inverter's device id, key, serial, firmware, feeding circuit or position. A consumer that
1197
+ needs one inverter reads `pv_inverters`.
1192
1198
  """
1193
1199
  pv_inverters: dict[str, SpanPVSnapshot] = field(default_factory=dict)
1194
1200
  """Every commissioned PV inverter, keyed by `SpanPVSnapshot.node_id`.
@@ -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.
@@ -40,7 +40,6 @@ def test_roles_are_sorted_by_declared_type_not_device_id() -> None:
40
40
  assert len(roles.lugs) == 2
41
41
  assert len(roles.evse) == 2
42
42
  assert roles.bess is not None and roles.bess.device_id == "bess"
43
- assert roles.pv is not None and roles.pv.device_id == "pv"
44
43
  assert [device.device_id for device in roles.pvs] == ["pv"]
45
44
  assert roles.mid is not None and roles.mid.device_id == "bess-mid"
46
45
 
@@ -161,9 +160,9 @@ def _multi_inverter_children() -> list[DiscoveredDevice]:
161
160
  """The capture with three inverters: two fed by a circuit each, one fed by none.
162
161
 
163
162
  The second inverter's circuit sits on lower breaker spaces than the captured
164
- solar circuit, so the rule choosing `snapshot.pv` has a reason to pick it.
165
- Only the second inverter publishes a serial; the others leave it unpublished,
166
- which is the common case.
163
+ solar circuit, which once made the library rank it first; nothing ranks
164
+ inverters now. Only the second inverter publishes a serial; the others leave
165
+ it unpublished, which is the common case.
167
166
  """
168
167
  tree = {device_id: dict(topics) for device_id, topics in _TREE.items()}
169
168
  pv_topics = tree.pop("pv")
@@ -195,7 +194,7 @@ def test_every_inverter_is_a_snapshot_keyed_by_its_feeding_circuit_or_its_device
195
194
 
196
195
 
197
196
  def test_every_inverters_feeding_circuit_is_labeled_pv() -> None:
198
- """Not only the one `snapshot.pv` describes: an unlabeled PV circuit reads as a load."""
197
+ """Every inverter's circuit, however many are commissioned: an unlabeled PV circuit reads as a load."""
199
198
  snapshot = build_snapshot(_device(PANEL), _multi_inverter_children())
200
199
 
201
200
  assert {cid for cid, c in snapshot.circuits.items() if c.device_type == "pv"} == {SOLAR_CIRCUIT, SECOND_SOLAR_CIRCUIT}
@@ -235,34 +234,88 @@ def test_every_circuit_feeding_an_inverter_is_labeled_pv_whatever_the_order() ->
235
234
  assert snapshot.pv_inverters[SOLAR_CIRCUIT].device_id == FIRST_PV
236
235
 
237
236
 
238
- def test_the_primary_inverter_is_chosen_by_breaker_space_not_by_tree_order() -> None:
237
+ def test_with_several_inverters_pv_describes_them_together() -> None:
238
+ """No inverter is primary: `pv` identifies none of them, whatever the tree order."""
239
239
  children = _multi_inverter_children()
240
240
  forward = build_snapshot(_device(PANEL), children)
241
241
  backward = build_snapshot(_device(PANEL), list(reversed(children)))
242
242
 
243
- assert forward.pv.device_id == SECOND_PV
244
- assert backward.pv == forward.pv
245
- assert forward.pv == forward.pv_inverters[SECOND_SOLAR_CIRCUIT]
243
+ assert forward.pv == backward.pv
244
+ assert forward.pv.device_id is None
245
+ assert forward.pv.node_id is None
246
+ assert forward.pv.feed_circuit_id is None
247
+ assert forward.pv.serial_number is None
248
+ assert forward.pv.software_version is None
249
+ assert forward.pv.relative_position is None
250
+ assert forward.pv.vendor_name == forward.pv_inverters[SOLAR_CIRCUIT].vendor_name
251
+ assert forward.pv.model == forward.pv_inverters[SOLAR_CIRCUIT].model
252
+ assert forward.pv.nameplate_capacity_w == 24000.0
253
+ # Two links reported up and one unreported: not known to be all up, not known down.
254
+ assert forward.pv.connected is None
255
+
256
+
257
+ def test_one_link_down_is_the_link_down() -> None:
258
+ """Three-valued: a link known down decides, whatever the others say.
259
+
260
+ The lost link is the first inverter's, on the higher breaker spaces, so the
261
+ removed lowest-space rule, which described the second inverter, read it up.
262
+ """
263
+ lost = device_from_topics(
264
+ SOLAR_CIRCUIT,
265
+ {**_TREE[SOLAR_CIRCUIT], "connection/feeds-device-id": FIRST_PV, "connection/feeds-device-status": "LOST"},
266
+ )
267
+ children = [lost if device.device_id == SOLAR_CIRCUIT else device for device in _multi_inverter_children()]
246
268
 
269
+ assert build_snapshot(_device(PANEL), children).pv.connected is False
247
270
 
248
- def test_an_inverter_without_a_feeding_circuit_is_primary_only_when_alone() -> None:
249
- lone = [device for device in _multi_inverter_children() if device.device_id not in (FIRST_PV, SECOND_PV)]
250
271
 
251
- snapshot = build_snapshot(_device(PANEL), lone)
272
+ def test_every_link_up_is_the_link_up() -> None:
273
+ fed = [device for device in _multi_inverter_children() if device.device_id != UNFED_PV]
274
+
275
+ assert build_snapshot(_device(PANEL), fed).pv.connected is True
276
+
277
+
278
+ def test_one_unpublished_nameplate_leaves_the_sum_unknown() -> None:
279
+ unsized = {topic: value for topic, value in _TREE["pv"].items() if topic != "info/nominal-power"}
280
+ children = [
281
+ device_from_topics(SECOND_PV, unsized) if device.device_id == SECOND_PV else device
282
+ for device in _multi_inverter_children()
283
+ ]
284
+
285
+ assert build_snapshot(_device(PANEL), children).pv.nameplate_capacity_w is None
286
+
287
+
288
+ def test_a_vendor_not_shared_is_unknown() -> None:
289
+ children = [
290
+ (
291
+ device_from_topics(SECOND_PV, {**_TREE["pv"], "info/vendor-name": "SolarEdge"})
292
+ if device.device_id == SECOND_PV
293
+ else device
294
+ )
295
+ for device in _multi_inverter_children()
296
+ ]
252
297
 
253
- assert snapshot.pv.device_id == UNFED_PV
254
- assert set(snapshot.pv_inverters) == {UNFED_PV}
298
+ assert build_snapshot(_device(PANEL), children).pv.vendor_name is None
255
299
 
256
300
 
257
- def test_unfed_inverters_fall_back_to_the_lowest_device_id() -> None:
258
- first = [device for device in _children() if device.device_id != "pv"]
259
- pv_topics = _TREE["pv"]
260
- unfed = [device_from_topics(device_id, pv_topics) for device_id in ("pv-b", "pv-a")]
301
+ def test_what_several_inverters_do_not_share_is_unknown() -> None:
302
+ children = [
303
+ device_from_topics(SECOND_PV, {**_TREE["pv"], "info/model": "SE7600H"}) if device.device_id == SECOND_PV else device
304
+ for device in _multi_inverter_children()
305
+ ]
261
306
 
262
- primary = TreeRoles([*first, *unfed]).pv
307
+ snapshot = build_snapshot(_device(PANEL), children)
308
+
309
+ assert snapshot.pv.model is None
310
+ assert snapshot.pv.vendor_name == snapshot.pv_inverters[SOLAR_CIRCUIT].vendor_name
311
+
312
+
313
+ def test_a_lone_inverter_is_pv_whatever_feeds_it() -> None:
314
+ lone = [device for device in _multi_inverter_children() if device.device_id not in (FIRST_PV, SECOND_PV)]
315
+
316
+ snapshot = build_snapshot(_device(PANEL), lone)
263
317
 
264
- assert primary is not None
265
- assert primary.device_id == "pv-a"
318
+ assert snapshot.pv == snapshot.pv_inverters[UNFED_PV]
266
319
 
267
320
 
268
321
  def test_the_serial_is_carried_where_published_and_absent_otherwise() -> None:
@@ -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)
File without changes