span-panel-api 3.5.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 (107) hide show
  1. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/CHANGELOG.md +25 -0
  2. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/PKG-INFO +23 -19
  3. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/README.md +20 -16
  4. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/pyproject.toml +8 -3
  5. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/__init__.py +6 -0
  6. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/auth.py +65 -7
  7. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/exceptions.py +16 -0
  8. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/factory.py +10 -1
  9. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/models.py +112 -16
  10. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_mqtt_homie.py +9 -0
  11. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_public_api_unchanged.py +5 -0
  12. span_panel_api-3.6.1/tests/test_register_passphrase_unavailable.py +148 -0
  13. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_one_adapter.py +101 -4
  14. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_one_charge_limit.py +37 -0
  15. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_one_connection_health.py +48 -0
  16. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_one_devices.py +128 -0
  17. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_one_discovery.py +5 -4
  18. span_panel_api-3.6.1/tests/test_schema_one_firmware.py +52 -0
  19. span_panel_api-3.6.1/tests/test_schema_one_snapshot.py +386 -0
  20. span_panel_api-3.6.1/tests/test_schema_one_undeclared_devices.py +166 -0
  21. span_panel_api-3.5.0/tests/test_schema_one_snapshot.py +0 -142
  22. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/.gitignore +0 -0
  23. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/LICENSE +0 -0
  24. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/_http.py +0 -0
  25. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/_ssl.py +0 -0
  26. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/adapters.py +0 -0
  27. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/const.py +0 -0
  28. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/detection.py +0 -0
  29. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/dispatch.py +0 -0
  30. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/__init__.py +0 -0
  31. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/async_client.py +0 -0
  32. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/client.py +0 -0
  33. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/connection.py +0 -0
  34. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/const.py +0 -0
  35. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/control.py +0 -0
  36. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/mqtt/models.py +0 -0
  37. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/phase_validation.py +0 -0
  38. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/protocol.py +0 -0
  39. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/py.typed +0 -0
  40. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/src/span_panel_api/schema_drift.py +0 -0
  41. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/conftest.py +0 -0
  42. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  43. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  44. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  45. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/fixtures/flat_wire.json +0 -0
  46. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  47. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/fixtures/panelbench_wire.json +0 -0
  48. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/fixtures/v2/README.md +0 -0
  49. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/fixtures/v2/status.json +0 -0
  50. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/reference_payloads/README.md +0 -0
  51. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/reference_payloads/__init__.py +0 -0
  52. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/reference_payloads/bootstrap.py +0 -0
  53. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/reference_payloads/schema_one.py +0 -0
  54. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/simulation_fixtures/circuits.response.txt +0 -0
  55. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/simulation_fixtures/panel.response.txt +0 -0
  56. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/simulation_fixtures/soe.response.txt +0 -0
  57. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/simulation_fixtures/status.response.txt +0 -0
  58. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_absent_readings_are_not_zero.py +0 -0
  59. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_accumulator.py +0 -0
  60. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_adapters_discovery.py +0 -0
  61. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_adopted_control.py +0 -0
  62. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_adoption.py +0 -0
  63. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_async_mqtt_client.py +0 -0
  64. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_auth_and_homie_helpers.py +0 -0
  65. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_auth_redaction.py +0 -0
  66. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_ca_pinning.py +0 -0
  67. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_catalog_divergence.py +0 -0
  68. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_control_interceptor.py +0 -0
  69. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_detection_auth.py +0 -0
  70. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_exceptions.py +0 -0
  71. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_factory_dispatch.py +0 -0
  72. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_field_metadata.py +0 -0
  73. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_https_transport.py +0 -0
  74. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_leaf_name_mismatch.py +0 -0
  75. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_live_flat_differential.py +0 -0
  76. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_mqtt_bridge.py +0 -0
  77. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_mqtt_client_connection.py +0 -0
  78. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_mqtt_connect_flow.py +0 -0
  79. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_mqtt_debounce.py +0 -0
  80. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_packaging.py +0 -0
  81. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_phase_validation_configs.py +0 -0
  82. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_phase_validation_errors.py +0 -0
  83. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_plaintext_warning.py +0 -0
  84. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_protocol_conformance.py +0 -0
  85. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_protocol_models.py +0 -0
  86. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_publish_outcome.py +0 -0
  87. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_redispatch_on_reconnect.py +0 -0
  88. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_reference_tree_values.py +0 -0
  89. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_rest_transport_contract.py +0 -0
  90. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_fetch_transport_split.py +0 -0
  91. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_generation_cross_check.py +0 -0
  92. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_migration_delta.py +0 -0
  93. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_one_circuits.py +0 -0
  94. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_one_conformance.py +0 -0
  95. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_one_control_refusal.py +0 -0
  96. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_one_extension.py +0 -0
  97. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_one_panel.py +0 -0
  98. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_one_pcs.py +0 -0
  99. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_one_service_entrance.py +0 -0
  100. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_one_shed_forecast.py +0 -0
  101. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_one_transport.py +0 -0
  102. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_provenance.py +0 -0
  103. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_schema_zero_adapter.py +0 -0
  104. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_shared_http_client.py +0 -0
  105. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_ssl_context.py +0 -0
  106. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/test_v2_status_parser.py +0 -0
  107. {span_panel_api-3.5.0 → span_panel_api-3.6.1}/tests/tls_fixtures.py +0 -0
@@ -7,6 +7,31 @@ 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.1]
11
+
12
+ The snapshot carries every PV inverter a panel commissions, and registration copes with a panel that cannot read its own passphrase.
13
+
14
+ ### Added
15
+
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
+ - **`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
+ - **`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
+ sets one.
20
+ - **`SpanPanelPassphraseUnavailableError`**, raised by `register_v2` and `create_span_client` for a panel that cannot read its own passphrase, is a `SpanPanelAPIError` rather than a `SpanPanelAuthError` because the passphrase the user gave may be correct.
21
+
22
+ ### Changed
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.
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.
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`.
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.
34
+
10
35
  ## [3.5.0]
11
36
 
12
37
  A rotation replaces the panel passphrase as well as the broker password, and the library now hands back both, so a caller no longer loses the only copy of the user's new passphrase. A broker that refuses the credentials is now reported as an authentication
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 3.5.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
@@ -20,9 +20,9 @@ Requires-Dist: httpx<1.0,>=0.28.1
20
20
  Requires-Dist: paho-mqtt<3.0.0,>=2.0.0
21
21
  Requires-Dist: pyyaml>=6.0.0
22
22
  Provides-Extra: schema-0
23
- Requires-Dist: span-panel-api-schema-0>=1.1.0; extra == 'schema-0'
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.1.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.5.0"
3
+ version = "3.6.1"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -58,8 +58,13 @@ dependencies = [
58
58
  # makes every public protocol member mandatory of every adapter wheel — so a 1.0.0
59
59
  # adapter installed against this bootstrap is rejected at discovery rather than
60
60
  # working in a degraded way. The two must move together.
61
- schema-0 = ["span-panel-api-schema-0>=1.1.0"]
62
- schema-1 = ["span-panel-api-schema-1>=1.1.0"]
61
+ # Raised to 1.2.0 for 3.6.0 for a different reason: no protocol member changed,
62
+ # so a 1.1.x adapter is still accepted, but it fills neither `pv_inverters` nor
63
+ # the inverter identity fields 3.6.0 adds. Upgrading through the extra is what
64
+ # brings the adapters that do.
65
+ schema-0 = ["span-panel-api-schema-0>=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"]
63
68
 
64
69
  [project.urls]
65
70
  Homepage = "https://github.com/SpanPanel/span-panel-api"
@@ -28,6 +28,7 @@ from .exceptions import (
28
28
  SpanPanelConnectionError,
29
29
  SpanPanelError,
30
30
  SpanPanelInsufficientPrivilegeError,
31
+ SpanPanelPassphraseUnavailableError,
31
32
  SpanPanelSchemaVersionError,
32
33
  SpanPanelServerError,
33
34
  SpanPanelStaleDataError,
@@ -210,6 +211,11 @@ __all__ = [ # noqa: RUF022
210
211
  # Added 2026-10-02 (3.5.0): a 403 from a reduced-privilege token. A subclass
211
212
  # of SpanPanelAuthError, so every existing except clause keeps its meaning.
212
213
  "SpanPanelInsufficientPrivilegeError",
214
+ # Added 2026-10-06 (3.6.1): registration reached a panel that cannot read its own
215
+ # passphrase. A subclass of SpanPanelAPIError, so existing except clauses
216
+ # keep their meaning; deliberately not a SpanPanelAuthError, because the
217
+ # passphrase the user supplied may be correct.
218
+ "SpanPanelPassphraseUnavailableError",
213
219
  "SpanPanelServerError",
214
220
  "SpanPanelStaleDataError",
215
221
  # Added 2026-08-31 (3.4.0): a bootstrap REST call that failed verification
@@ -19,7 +19,13 @@ import uuid
19
19
  import httpx
20
20
 
21
21
  from ._http import CA_CERT_PATH, V2_STATUS_PATH, _Reply, _request
22
- from .exceptions import SpanPanelAPIError, SpanPanelAuthError, SpanPanelInsufficientPrivilegeError, SpanPanelServerError
22
+ from .exceptions import (
23
+ SpanPanelAPIError,
24
+ SpanPanelAuthError,
25
+ SpanPanelInsufficientPrivilegeError,
26
+ SpanPanelPassphraseUnavailableError,
27
+ SpanPanelServerError,
28
+ )
23
29
  from .models import HomieSchemaTypes, PassphraseRotation, V2AuthResponse, V2HomieSchema, V2StatusInfo
24
30
 
25
31
  _LOGGER = logging.getLogger(__name__)
@@ -171,6 +177,11 @@ def _str(val: object) -> str:
171
177
  return str(val) if val is not None else ""
172
178
 
173
179
 
180
+ def _optional_str(val: object) -> str | None:
181
+ """Extract a string from a JSON-decoded value that the panel may send as null or omit."""
182
+ return None if val is None else str(val)
183
+
184
+
174
185
  def _int(val: object) -> int:
175
186
  """Extract an int from a JSON-decoded value."""
176
187
  if isinstance(val, int):
@@ -182,6 +193,24 @@ def _int(val: object) -> int:
182
193
 
183
194
  HTTP_TOO_MANY_REQUESTS = 429
184
195
 
196
+ #: The ``detail`` a registration 422 carries when the panel cannot read its own
197
+ #: passphrase. Compared whole and quoted in the exception message, which is safe
198
+ #: only because it is this fixed string and never anything the panel echoed.
199
+ REGISTRATION_UNAVAILABLE_DETAIL = "Dashboard password is not available"
200
+
201
+
202
+ def _error_detail(response: httpx.Response) -> str | None:
203
+ """The ``detail`` string of an error body, or None when there is no such string."""
204
+ try:
205
+ parsed = response.json()
206
+ except ValueError:
207
+ return None
208
+ if not isinstance(parsed, dict):
209
+ return None
210
+ detail = parsed.get("detail")
211
+ return detail if isinstance(detail, str) else None
212
+
213
+
185
214
  #: Default attempts and base backoff used when the panel rate-limits a request.
186
215
  CA_CERT_MAX_ATTEMPTS = 5
187
216
  CA_CERT_BACKOFF_S = 1.5
@@ -240,10 +269,15 @@ async def register_v2(
240
269
  this call to ``https://``; ``None`` is byte-identical to 3.0.1.
241
270
 
242
271
  Returns:
243
- V2AuthResponse with access token and MQTT broker credentials
272
+ V2AuthResponse with access token and MQTT broker credentials. From firmware
273
+ r202639 the broker password and passphrase are ``None`` when the panel
274
+ cannot read its passphrase; the access token is still valid.
244
275
 
245
276
  Raises:
246
277
  SpanPanelAuthError: Invalid passphrase or auth failure
278
+ SpanPanelPassphraseUnavailableError: The panel cannot read its own passphrase
279
+ SpanPanelServerError: The panel is not ready to register clients (any 5xx,
280
+ including the 503 it answers before its serial number is known); retryable
247
281
  SpanPanelConnectionError: Cannot reach panel
248
282
  SpanPanelTimeoutError: Request timed out
249
283
  SpanPanelAPIError: Unexpected response
@@ -267,6 +301,29 @@ async def register_v2(
267
301
  json=payload,
268
302
  )
269
303
 
304
+ sent = () if passphrase is None else (passphrase,)
305
+
306
+ if reply.status_code >= 500:
307
+ # From r202639 the panel answers 503 until it knows its own serial
308
+ # number, which is a panel still starting rather than a refusal. The
309
+ # same class `get_homie_schema` raises for a booting panel, so one retry
310
+ # clause covers both.
311
+ _log_auth_failure(reply.endpoint, reply.response, sent)
312
+ raise SpanPanelServerError(
313
+ f"Panel not ready: HTTP {reply.status_code} from /api/v2/auth/register",
314
+ status_code=reply.status_code,
315
+ )
316
+
317
+ if reply.status_code == 422 and _error_detail(reply.response) == REGISTRATION_UNAVAILABLE_DETAIL:
318
+ # Checked before the general 422 below, which means "credential not
319
+ # accepted". This one is the panel failing to read its own passphrase,
320
+ # and telling a user theirs is wrong would be false.
321
+ _log_auth_failure(reply.endpoint, reply.response, sent)
322
+ raise SpanPanelPassphraseUnavailableError(
323
+ f"Panel cannot register clients: {REGISTRATION_UNAVAILABLE_DETAIL} (HTTP 422)",
324
+ status_code=reply.status_code,
325
+ )
326
+
270
327
  if reply.status_code in (401, 403, 422):
271
328
  # Status only, matching the shape the branch below already uses. The body
272
329
  # is logged at DEBUG instead: a 422 from the panel's validation layer
@@ -276,39 +333,40 @@ async def register_v2(
276
333
  # passphrase goes with it because this is the one place in the library
277
334
  # that knows what was sent, and the panel is under no obligation to
278
335
  # quote it back under a key that names it.
279
- _log_auth_failure(reply.endpoint, reply.response, () if passphrase is None else (passphrase,))
336
+ _log_auth_failure(reply.endpoint, reply.response, sent)
280
337
  raise SpanPanelAuthError(f"Authentication failed (HTTP {reply.status_code})")
281
338
 
282
339
  if reply.status_code != 200:
283
340
  raise SpanPanelAPIError(f"Unexpected response from /api/v2/auth/register: HTTP {reply.status_code}")
284
341
 
342
+ # The two credentials are not required: from r202639 the panel sends them
343
+ # as null, or may omit them, when it cannot read its passphrase, and the
344
+ # access token beside them is still good. Both read as None either way.
285
345
  data = reply.json_object(
286
346
  "accessToken",
287
347
  "tokenType",
288
348
  "iatMs",
289
349
  "ebusBrokerUsername",
290
- "ebusBrokerPassword",
291
350
  "ebusBrokerHost",
292
351
  "ebusBrokerMqttsPort",
293
352
  "ebusBrokerWsPort",
294
353
  "ebusBrokerWssPort",
295
354
  "hostname",
296
355
  "serialNumber",
297
- "hopPassphrase",
298
356
  )
299
357
  return V2AuthResponse(
300
358
  access_token=_str(data["accessToken"]),
301
359
  token_type=_str(data["tokenType"]),
302
360
  iat_ms=_int(data["iatMs"]),
303
361
  ebus_broker_username=_str(data["ebusBrokerUsername"]),
304
- ebus_broker_password=_str(data["ebusBrokerPassword"]),
362
+ ebus_broker_password=_optional_str(data.get("ebusBrokerPassword")),
305
363
  ebus_broker_host=_str(data["ebusBrokerHost"]),
306
364
  ebus_broker_mqtts_port=_int(data["ebusBrokerMqttsPort"]),
307
365
  ebus_broker_ws_port=_int(data["ebusBrokerWsPort"]),
308
366
  ebus_broker_wss_port=_int(data["ebusBrokerWssPort"]),
309
367
  hostname=_str(data["hostname"]),
310
368
  serial_number=_str(data["serialNumber"]),
311
- hop_passphrase=_str(data["hopPassphrase"]),
369
+ hop_passphrase=_optional_str(data.get("hopPassphrase")),
312
370
  )
313
371
 
314
372
 
@@ -68,6 +68,22 @@ class SpanPanelServerError(SpanPanelAPIError):
68
68
  """
69
69
 
70
70
 
71
+ class SpanPanelPassphraseUnavailableError(SpanPanelAPIError):
72
+ """The panel cannot read its own passphrase, so it cannot issue broker credentials.
73
+
74
+ A fault on the panel, not a rejected credential, which is why this is not a
75
+ `SpanPanelAuthError`: a caller that maps that class to "wrong passphrase"
76
+ would send the user back to retype one that may well be correct.
77
+
78
+ Raised in two places. ``register_v2`` raises it for the 422 a panel answers
79
+ when registration needs the passphrase and the panel cannot read it.
80
+ ``create_span_client`` raises it when registration succeeded but returned no
81
+ broker password, which from firmware r202639 is how a panel in the same
82
+ state answers a registration it can otherwise complete; connecting to the
83
+ broker without a password could only fail later and less clearly.
84
+ """
85
+
86
+
71
87
  class SpanPanelCAChangedError(SpanPanelError):
72
88
  """The panel is presenting a certificate chain from a different CA than the pin.
73
89
 
@@ -15,7 +15,7 @@ from .adapters import resolve_adapter
15
15
  from .auth import get_homie_schema, register_v2
16
16
  from .detection import detect_api_version
17
17
  from .dispatch import select_adapter_key
18
- from .exceptions import SpanPanelAuthError
18
+ from .exceptions import SpanPanelAuthError, SpanPanelPassphraseUnavailableError
19
19
  from .mqtt.client import SpanMqttClient
20
20
  from .mqtt.models import MqttClientConfig
21
21
 
@@ -77,6 +77,10 @@ async def create_span_client(
77
77
  Raises:
78
78
  SpanPanelAuthError: Neither mqtt_config nor passphrase provided,
79
79
  or serial_number could not be determined.
80
+ SpanPanelPassphraseUnavailableError: Registration was attempted and the panel
81
+ cannot read its own passphrase, so it issued no broker password.
82
+ SpanPanelServerError: Registration was attempted and the panel is not ready
83
+ to register clients yet; retryable.
80
84
  SpanPanelConnectionError: Cannot reach panel during detection or registration.
81
85
  SpanPanelTimeoutError: Timeout during detection or registration.
82
86
  SpanPanelSchemaVersionError: The panel reports a data-model-version whose
@@ -90,6 +94,11 @@ async def create_span_client(
90
94
  auth_response = await register_v2(
91
95
  host, _V2_CLIENT_NAME, passphrase, port=port, httpx_client=httpx_client, ssl_context=ssl_context
92
96
  )
97
+ if auth_response.ebus_broker_password is None:
98
+ raise SpanPanelPassphraseUnavailableError(
99
+ "Panel registration returned no MQTT broker password because the panel cannot read "
100
+ "its passphrase; the broker cannot be reached until that is resolved"
101
+ )
93
102
  mqtt_config = MqttClientConfig(
94
103
  broker_host=auth_response.ebus_broker_host,
95
104
  username=auth_response.ebus_broker_username,
@@ -74,11 +74,25 @@ class SpanCircuitSnapshot:
74
74
 
75
75
  @dataclass(frozen=True, slots=True)
76
76
  class SpanPVSnapshot:
77
- """PV inverter metadata — populated only when a PV node is commissioned."""
77
+ """One PV inverter's metadata, populated only when a PV device is commissioned.
78
+
79
+ A panel may commission more than one inverter, and from firmware r202639 each
80
+ is published as its own device. `SpanPanelSnapshot.pv_inverters` carries all
81
+ of them; `SpanPanelSnapshot.pv` is the lone inverter, or describes several
82
+ together, as that field documents.
83
+ """
78
84
 
79
85
  vendor_name: str | None = None # pv/vendor-name
80
86
  model: str | None = None # human designation (v1.0 info/model; flat pv/product-name)
81
- nameplate_capacity_w: float | None = None # pv/nameplate-capacity (W)
87
+ nameplate_capacity_w: float | None = None
88
+ """The PV array's DC size as recorded at installation, in watts. Informational.
89
+
90
+ v1.0 `info/nominal-power`; flat `pv/nameplate-capacity`. A figure entered at
91
+ commissioning, never a measured or enforced limit: the inverter can produce
92
+ more or less than this, so a consumer must not treat it as a ceiling on PV
93
+ power or clamp a reading to it.
94
+ """
95
+
82
96
  feed_circuit_id: str | None = None # pv/feed (normalized circuit ID)
83
97
  relative_position: str | None = None # pv/relative-position (IN_PANEL | UPSTREAM | DOWNSTREAM)
84
98
  software_version: str | None = None
@@ -104,6 +118,36 @@ class SpanPVSnapshot:
104
118
  it does not know) and is deliberately distinct from `False`. The enum has
105
119
  three members, `OK,LOST,DEGRADED`, and no UNKNOWN, so absence is the only
106
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`.
125
+ """
126
+
127
+ serial_number: str | None = None
128
+ """`info/serial-number`, v1.0 only. Frequently unpublished, and that is normal.
129
+
130
+ An inverter's serial is often not recorded at commissioning, so `None` is
131
+ the ordinary case rather than a fault. Shown on a device card, never used as
132
+ an identity: see `node_id`.
133
+ """
134
+
135
+ device_id: str | None = None
136
+ """The inverter's id on the wire: the v1.0 device id, or the flat node id.
137
+
138
+ For addressing and diagnostics, never for identity. On a panel with more
139
+ than one inverter every PV device id changes at firmware r202639, the one
140
+ published before included.
141
+ """
142
+
143
+ node_id: str | None = None
144
+ """This inverter's key in `SpanPanelSnapshot.pv_inverters`; `None` on the empty snapshot.
145
+
146
+ Named after `SpanEvseSnapshot.node_id`, which plays the same role for
147
+ chargers. It is the feeding circuit's id when a circuit feeds the inverter,
148
+ because that id stays put when the inverter's own device id changes, and
149
+ `device_id` otherwise. `None` also on a `pv` that describes several
150
+ inverters together.
107
151
  """
108
152
 
109
153
 
@@ -310,7 +354,13 @@ class SpanEvseSnapshot:
310
354
 
311
355
  `None` means the charger declares no such property — `charge-limit.md` reads
312
356
  that as "no adjustable charge-current ceiling; it charges at a fixed rate" —
313
- or that it has not published a value yet.
357
+ or that it has not published a value yet, or, from firmware r202639, that
358
+ no user has set a limit. That release publishes a value only once a user
359
+ sets one, leaving `charge_current_ceiling_a` as the limit in force; earlier
360
+ releases filled it with the ceiling on their own, and that retained value
361
+ can outlive the upgrade. A value equal to the ceiling therefore means the
362
+ same as `None`. Read `effective_charge_current_limit_a` for the limit the
363
+ charger is actually applying.
314
364
 
315
365
  **Not `advertised_current_a`.** That is the current actually being offered
316
366
  to the vehicle, which the capability defines as the `min()` of this, the
@@ -360,6 +410,20 @@ class SpanEvseSnapshot:
360
410
  cases.
361
411
  """
362
412
 
413
+ @property
414
+ def effective_charge_current_limit_a(self) -> int | None:
415
+ """The charge-current limit in force, in amps: the user's, else the ceiling.
416
+
417
+ `charge_current_limit_a` when one is published, otherwise
418
+ `charge_current_ceiling_a`, which is the limit from r202639 whenever no
419
+ user has set one. A stale retained limit equal to the ceiling resolves
420
+ to the same number either way, so no special case is needed for it.
421
+ `None` only when neither half is published.
422
+ """
423
+ if self.charge_current_limit_a is not None:
424
+ return self.charge_current_limit_a
425
+ return self.charge_current_ceiling_a
426
+
363
427
 
364
428
  @dataclass(frozen=True, slots=True)
365
429
  class SpanBatterySnapshot:
@@ -379,23 +443,24 @@ class SpanBatterySnapshot:
379
443
  nameplate_capacity_kwh: float | None = None # bess/nameplate-capacity (kWh)
380
444
  connected: bool | None = None # bess/connected
381
445
 
382
- # The BESS's own `meter/active-power`, v1.0 only. **Charge-positive**, which
383
- # is a sign flip away from the wire: the enclosure meters the BESS the way it
384
- # meters a circuit, so a charging battery reads negative there and positive
385
- # here, exactly as `SpanCircuitSnapshot.instant_power_w` reports a load's
386
- # consumption positive. The snapshot's rule across every power field is that
446
+ # The BESS's own `meter/active-power`, v1.0 only. **Discharge-positive**:
387
447
  # positive means power flowing *out of* the battery, which is discharging.
388
448
  # That is the frame the eBus specification asks of a device's own meter, and
389
- # it is deliberately NOT the into-the-device rule the circuit fields follow:
390
- # the wire input is in the opposite frame, so one negation lands here rather
391
- # than there. Measured against a producer in self-consumption with the grid
392
- # at zero, where the direction cannot be argued.
449
+ # it is deliberately NOT the into-the-device rule
450
+ # `SpanCircuitSnapshot.instant_power_w` follows. Measured against a producer
451
+ # in self-consumption with the grid at zero, where the direction cannot be
452
+ # argued.
453
+ #
454
+ # The wire frame changed under this field at firmware r202639: earlier
455
+ # releases publish the BESS meter charge-positive and the adapter negates it;
456
+ # r202639 and later publish it discharge-positive and it passes through. The
457
+ # field's own convention is the same on both.
393
458
  #
394
459
  # Distinct from `SpanPanelSnapshot.power_flow_battery`, which is the
395
460
  # enclosure's own arbitrated flow figure, passed through untouched and
396
461
  # charge-positive. The two describe the same physical power in opposite
397
462
  # frames, so a consumer rendering both must negate one of them; this one is
398
- # already negated.
463
+ # already in the discharge-positive frame.
399
464
  power_w: float | None = None # v2: bess meter/active-power (W), discharge-positive
400
465
 
401
466
  # `status/communication-state`, v1.0 only: the BESS publisher's report of its
@@ -513,20 +578,27 @@ class DiscoveredMetadata(FieldMetadata):
513
578
 
514
579
  @dataclass(frozen=True, slots=True)
515
580
  class V2AuthResponse:
516
- """Response from POST /api/v2/auth/register."""
581
+ """Response from POST /api/v2/auth/register.
582
+
583
+ ``ebus_broker_password`` and ``hop_passphrase`` are ``None`` when the panel
584
+ could not read its passphrase. From firmware r202639 that no longer fails
585
+ the registration: the access token is still issued and REST calls work, but
586
+ there is no broker password to connect with. Earlier firmware always
587
+ supplied both.
588
+ """
517
589
 
518
590
  access_token: str
519
591
  token_type: str
520
592
  iat_ms: int
521
593
  ebus_broker_username: str
522
- ebus_broker_password: str # Use this for MQTT, NOT hop_passphrase
594
+ ebus_broker_password: str | None # Use this for MQTT, NOT hop_passphrase
523
595
  ebus_broker_host: str
524
596
  ebus_broker_mqtts_port: int
525
597
  ebus_broker_ws_port: int
526
598
  ebus_broker_wss_port: int
527
599
  hostname: str
528
600
  serial_number: str
529
- hop_passphrase: str # For REST auth; currently the same value as ebus_broker_password
601
+ hop_passphrase: str | None # For REST auth; currently the same value as ebus_broker_password
530
602
 
531
603
 
532
604
  @dataclass(frozen=True, slots=True)
@@ -893,6 +965,10 @@ class ExtensionSubject:
893
965
  The EVSE's `node_id` and the circuit's `circuit_id` -- the same keys
894
966
  `snapshot.evse` and `snapshot.circuits` use, so a consumer holding the
895
967
  snapshot resolves the subject with a lookup it already performs.
968
+
969
+ `pv` is a singleton while the panel publishes one inverter. With more than
970
+ one, each inverter's subject carries its `SpanPVSnapshot.node_id`, the key
971
+ `snapshot.pv_inverters` uses.
896
972
  """
897
973
 
898
974
 
@@ -1112,6 +1188,26 @@ class SpanPanelSnapshot:
1112
1188
  circuits: dict[str, SpanCircuitSnapshot] = field(default_factory=dict)
1113
1189
  battery: SpanBatterySnapshot = field(default_factory=SpanBatterySnapshot)
1114
1190
  pv: SpanPVSnapshot = field(default_factory=SpanPVSnapshot)
1191
+ """The lone PV inverter, or the inverters together; the empty snapshot when none is commissioned.
1192
+
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`.
1198
+ """
1199
+ pv_inverters: dict[str, SpanPVSnapshot] = field(default_factory=dict)
1200
+ """Every commissioned PV inverter, keyed by `SpanPVSnapshot.node_id`.
1201
+
1202
+ The key is the feeding circuit's id where a circuit feeds the inverter, and
1203
+ the inverter's own device id otherwise. Preferring the circuit keeps the key
1204
+ still across firmware r202639, which changes every PV device id on a panel
1205
+ with more than one inverter but leaves circuit ids alone.
1206
+
1207
+ Empty when no inverter is commissioned, and from an adapter that predates
1208
+ the field; a consumer falls back to `pv` in that case. A defaulted snapshot
1209
+ field for the reason `adopted_devices` gives.
1210
+ """
1115
1211
  evse: dict[str, SpanEvseSnapshot] = field(default_factory=dict) # keyed by serial (see SpanEvseSnapshot.node_id)
1116
1212
  mid: SpanMidSnapshot | None = None
1117
1213
  """The islanding authority, when the panel publishes one. v1.0 only.