span-panel-api 3.5.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.
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/CHANGELOG.md +27 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/PKG-INFO +23 -19
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/README.md +20 -16
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/pyproject.toml +8 -3
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/__init__.py +6 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/auth.py +65 -7
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/exceptions.py +16 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/factory.py +10 -1
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/models.py +112 -16
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_mqtt_homie.py +9 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_public_api_unchanged.py +5 -0
- span_panel_api-3.6.1b1/tests/test_register_passphrase_unavailable.py +148 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_adapter.py +101 -4
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_charge_limit.py +37 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_connection_health.py +48 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_devices.py +128 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_discovery.py +5 -4
- span_panel_api-3.6.1b1/tests/test_schema_one_firmware.py +52 -0
- span_panel_api-3.6.1b1/tests/test_schema_one_snapshot.py +386 -0
- span_panel_api-3.5.0/tests/test_schema_one_snapshot.py +0 -142
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/.gitignore +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/LICENSE +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/_ssl.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/mqtt/client.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/conftest.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/fixtures/panelbench_wire.json +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/reference_payloads/README.md +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/reference_payloads/bootstrap.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/reference_payloads/schema_one.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_adoption.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_ca_pinning.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_https_transport.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_leaf_name_mismatch.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_packaging.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_plaintext_warning.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_rest_transport_contract.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_fetch_transport_split.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_control_refusal.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_panel.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_ssl_context.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/test_v2_status_parser.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.1b1}/tests/tls_fixtures.py +0 -0
|
@@ -7,6 +7,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
7
7
|
Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the **last public release**, never against the beta before it. What one
|
|
8
8
|
beta corrected in an earlier beta does not appear at all: from the point of view of somebody upgrading between released versions, it never happened.
|
|
9
9
|
|
|
10
|
+
## [3.6.1b1]
|
|
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]
|
|
19
|
+
|
|
20
|
+
The snapshot carries every PV inverter a panel commissions, and registration copes with a panel that cannot read its own passphrase.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **`SpanPanelSnapshot.pv_inverters`** carries every commissioned PV inverter, keyed by its feeding circuit's id, which stays put when firmware r202639 renames a panel's inverters, or by its device id where no circuit feeds it.
|
|
25
|
+
- **`SpanPVSnapshot.serial_number`, `device_id` and `node_id`** give an inverter's serial number when one is published, its id on the wire, and its key in `pv_inverters`.
|
|
26
|
+
- **`SpanEvseSnapshot.effective_charge_current_limit_a`** is the charge-current limit a charger is applying, its user limit when one is published and its ceiling otherwise, since from firmware r202639 a SPAN Drive publishes a user limit only once someone
|
|
27
|
+
sets one.
|
|
28
|
+
- **`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.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **`V2AuthResponse.ebus_broker_password` and `hop_passphrase` are `str | None`**, `None` when a panel on firmware r202639 or later cannot read its passphrase yet still issues a valid access token.
|
|
33
|
+
- **`register_v2` raises `SpanPanelServerError` with `status_code` for any 5xx**, including the 503 a panel on firmware r202639 answers until it knows its serial number, where it raised a plain `SpanPanelAPIError`.
|
|
34
|
+
- **`SpanPanelSnapshot.pv` is the inverter on the lowest breaker space when more than one is commissioned**, rather than whichever one the adapter met first.
|
|
35
|
+
- **`SpanPVSnapshot.nameplate_capacity_w` is documented as the array's DC size recorded at installation**, an informational figure and never a ceiling on PV power.
|
|
36
|
+
|
|
10
37
|
## [3.5.0]
|
|
11
38
|
|
|
12
39
|
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.
|
|
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
|
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
3
|
+
version = "3.6.1b1"
|
|
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
|
-
|
|
62
|
-
|
|
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.1b1 for 3.6.1b1: that adapter stops ranking one inverter as pv.
|
|
67
|
+
schema-1 = ["span-panel-api-schema-1>=1.2.1b1"]
|
|
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.0): 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
|
|
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,
|
|
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=
|
|
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=
|
|
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
|
|
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
|
|
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. **
|
|
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
|
|
390
|
-
#
|
|
391
|
-
#
|
|
392
|
-
#
|
|
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
|
|
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.
|