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.
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/CHANGELOG.md +10 -4
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/PKG-INFO +22 -18
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/README.md +20 -16
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/pyproject.toml +3 -2
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/__init__.py +1 -1
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/models.py +14 -8
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_public_api_unchanged.py +1 -1
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_snapshot.py +74 -21
- span_panel_api-3.6.1/tests/test_schema_one_undeclared_devices.py +166 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/.gitignore +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/LICENSE +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/_ssl.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/auth.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/client.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/conftest.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/panelbench_wire.json +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/reference_payloads/README.md +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/reference_payloads/bootstrap.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/reference_payloads/schema_one.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_adoption.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_ca_pinning.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_https_transport.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_leaf_name_mismatch.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_mqtt_homie.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_packaging.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_plaintext_warning.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_register_passphrase_unavailable.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_rest_transport_contract.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_fetch_transport_split.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_adapter.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_control_refusal.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_discovery.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_firmware.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_panel.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_ssl_context.py +0 -0
- {span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/test_v2_status_parser.py +0 -0
- {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.
|
|
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
|
|
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
|
-
-
|
|
27
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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`
|
|
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
|
-
"""
|
|
1191
|
+
"""The lone PV inverter, or the inverters together; the empty snapshot when none is commissioned.
|
|
1186
1192
|
|
|
1187
|
-
With
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
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.
|
|
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,
|
|
165
|
-
Only the second inverter publishes a serial; the others leave
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
244
|
-
assert
|
|
245
|
-
assert forward.pv
|
|
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
|
-
|
|
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
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/fixtures/panelbench_unvalued_by_both.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.6.0 → span_panel_api-3.6.1}/tests/simulation_fixtures/circuits.response.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|