span-panel-api 3.6.0__tar.gz → 3.6.1b1__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 (105) hide show
  1. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/CHANGELOG.md +9 -1
  2. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/PKG-INFO +22 -18
  3. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/README.md +20 -16
  4. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/pyproject.toml +3 -2
  5. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/models.py +14 -8
  6. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_snapshot.py +74 -21
  7. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/.gitignore +0 -0
  8. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/LICENSE +0 -0
  9. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/__init__.py +0 -0
  10. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/_http.py +0 -0
  11. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/_ssl.py +0 -0
  12. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/adapters.py +0 -0
  13. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/auth.py +0 -0
  14. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/const.py +0 -0
  15. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/detection.py +0 -0
  16. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/dispatch.py +0 -0
  17. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/exceptions.py +0 -0
  18. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/factory.py +0 -0
  19. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/mqtt/__init__.py +0 -0
  20. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/mqtt/async_client.py +0 -0
  21. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/mqtt/client.py +0 -0
  22. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/mqtt/connection.py +0 -0
  23. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/mqtt/const.py +0 -0
  24. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/mqtt/control.py +0 -0
  25. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/mqtt/models.py +0 -0
  26. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/phase_validation.py +0 -0
  27. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/protocol.py +0 -0
  28. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/py.typed +0 -0
  29. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/src/span_panel_api/schema_drift.py +0 -0
  30. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/conftest.py +0 -0
  31. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  32. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  33. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  34. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/fixtures/flat_wire.json +0 -0
  35. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  36. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/fixtures/panelbench_wire.json +0 -0
  37. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/fixtures/v2/README.md +0 -0
  38. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/fixtures/v2/status.json +0 -0
  39. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/reference_payloads/README.md +0 -0
  40. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/reference_payloads/__init__.py +0 -0
  41. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/reference_payloads/bootstrap.py +0 -0
  42. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/reference_payloads/schema_one.py +0 -0
  43. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/simulation_fixtures/circuits.response.txt +0 -0
  44. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/simulation_fixtures/panel.response.txt +0 -0
  45. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/simulation_fixtures/soe.response.txt +0 -0
  46. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/simulation_fixtures/status.response.txt +0 -0
  47. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_absent_readings_are_not_zero.py +0 -0
  48. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_accumulator.py +0 -0
  49. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_adapters_discovery.py +0 -0
  50. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_adopted_control.py +0 -0
  51. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_adoption.py +0 -0
  52. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_async_mqtt_client.py +0 -0
  53. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_auth_and_homie_helpers.py +0 -0
  54. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_auth_redaction.py +0 -0
  55. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_ca_pinning.py +0 -0
  56. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_catalog_divergence.py +0 -0
  57. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_control_interceptor.py +0 -0
  58. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_detection_auth.py +0 -0
  59. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_exceptions.py +0 -0
  60. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_factory_dispatch.py +0 -0
  61. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_field_metadata.py +0 -0
  62. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_https_transport.py +0 -0
  63. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_leaf_name_mismatch.py +0 -0
  64. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_live_flat_differential.py +0 -0
  65. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_mqtt_bridge.py +0 -0
  66. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_mqtt_client_connection.py +0 -0
  67. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_mqtt_connect_flow.py +0 -0
  68. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_mqtt_debounce.py +0 -0
  69. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_mqtt_homie.py +0 -0
  70. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_packaging.py +0 -0
  71. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_phase_validation_configs.py +0 -0
  72. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_phase_validation_errors.py +0 -0
  73. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_plaintext_warning.py +0 -0
  74. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_protocol_conformance.py +0 -0
  75. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_protocol_models.py +0 -0
  76. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_public_api_unchanged.py +0 -0
  77. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_publish_outcome.py +0 -0
  78. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_redispatch_on_reconnect.py +0 -0
  79. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_reference_tree_values.py +0 -0
  80. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_register_passphrase_unavailable.py +0 -0
  81. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_rest_transport_contract.py +0 -0
  82. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_fetch_transport_split.py +0 -0
  83. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_generation_cross_check.py +0 -0
  84. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_migration_delta.py +0 -0
  85. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_adapter.py +0 -0
  86. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_charge_limit.py +0 -0
  87. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_circuits.py +0 -0
  88. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_conformance.py +0 -0
  89. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_connection_health.py +0 -0
  90. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_control_refusal.py +0 -0
  91. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_devices.py +0 -0
  92. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_discovery.py +0 -0
  93. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_extension.py +0 -0
  94. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_firmware.py +0 -0
  95. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_panel.py +0 -0
  96. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_pcs.py +0 -0
  97. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_service_entrance.py +0 -0
  98. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_shed_forecast.py +0 -0
  99. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_transport.py +0 -0
  100. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_provenance.py +0 -0
  101. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_schema_zero_adapter.py +0 -0
  102. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_shared_http_client.py +0 -0
  103. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_ssl_context.py +0 -0
  104. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/test_v2_status_parser.py +0 -0
  105. {span_panel_api-3.6.0 → span_panel_api-3.6.1b1}/tests/tls_fixtures.py +0 -0
@@ -7,7 +7,15 @@ 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.1b1]
11
+
12
+ 3.6.0 was withdrawn from PyPI; this release carries its changes, listed under 3.6.0 below, except how `pv` is chosen, which this release replaces.
13
+
14
+ ### Changed
15
+
16
+ - **`SpanPanelSnapshot.pv` is documented as the lone inverter or the inverters together**, and the `schema-1` extra requires `span-panel-api-schema-1` 1.2.1 or newer.
17
+
18
+ ## [3.6.0] [YANKED]
11
19
 
12
20
  The snapshot carries every PV inverter a panel commissions, and registration copes with a panel that cannot read its own passphrase.
13
21
 
@@ -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.1b1
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.1b1; 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.1b1"
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.1b1 for 3.6.1b1: that adapter stops ranking one inverter as pv.
67
+ schema-1 = ["span-panel-api-schema-1>=1.2.1b1"]
67
68
 
68
69
  [project.urls]
69
70
  Homepage = "https://github.com/SpanPanel/span-panel-api"
@@ -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`.
@@ -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:
File without changes