span-panel-api 3.5.0__tar.gz → 3.6.0__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.0}/CHANGELOG.md +19 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/PKG-INFO +3 -3
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/pyproject.toml +7 -3
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/__init__.py +6 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/auth.py +65 -7
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/exceptions.py +16 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/factory.py +10 -1
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/models.py +106 -16
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_mqtt_homie.py +9 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_public_api_unchanged.py +5 -0
- span_panel_api-3.6.0/tests/test_register_passphrase_unavailable.py +148 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_adapter.py +101 -4
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_charge_limit.py +37 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_connection_health.py +48 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_devices.py +128 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_discovery.py +5 -4
- span_panel_api-3.6.0/tests/test_schema_one_firmware.py +52 -0
- span_panel_api-3.6.0/tests/test_schema_one_snapshot.py +333 -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.0}/.gitignore +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/LICENSE +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/README.md +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/_ssl.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/client.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/conftest.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/panelbench_wire.json +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/reference_payloads/README.md +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/reference_payloads/bootstrap.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/reference_payloads/schema_one.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_adoption.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_ca_pinning.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_https_transport.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_leaf_name_mismatch.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_packaging.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_plaintext_warning.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_rest_transport_contract.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_fetch_transport_split.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_control_refusal.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_panel.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_ssl_context.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_v2_status_parser.py +0 -0
- {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/tls_fixtures.py +0 -0
|
@@ -7,6 +7,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
7
7
|
Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the **last public release**, never against the beta before it. What one
|
|
8
8
|
beta corrected in an earlier beta does not appear at all: from the point of view of somebody upgrading between released versions, it never happened.
|
|
9
9
|
|
|
10
|
+
## [3.6.0]
|
|
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 renames a panel's inverters, 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
|
+
- **`V2AuthResponse.ebus_broker_password` and `hop_passphrase` are `str | None`**, `None` when a panel on firmware r202639 or later cannot read its passphrase yet still issues a valid access token.
|
|
25
|
+
- **`register_v2` raises `SpanPanelServerError` with `status_code` for any 5xx**, including the 503 a panel on firmware r202639 answers until it knows its serial number, where it raised a plain `SpanPanelAPIError`.
|
|
26
|
+
- **`SpanPanelSnapshot.pv` is the inverter on the lowest breaker space when more than one is commissioned**, rather than whichever one the adapter met first.
|
|
27
|
+
- **`SpanPVSnapshot.nameplate_capacity_w` is documented as the array's DC size recorded at installation**, an informational figure and never a ceiling on PV power.
|
|
28
|
+
|
|
10
29
|
## [3.5.0]
|
|
11
30
|
|
|
12
31
|
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.0
|
|
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.0; extra == 'schema-1'
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
28
28
|
# SPAN Panel API
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "span-panel-api"
|
|
3
|
-
version = "3.
|
|
3
|
+
version = "3.6.0"
|
|
4
4
|
description = "A client library for SPAN Panel API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "SpanPanel"}
|
|
@@ -58,8 +58,12 @@ 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
|
+
schema-1 = ["span-panel-api-schema-1>=1.2.0"]
|
|
63
67
|
|
|
64
68
|
[project.urls]
|
|
65
69
|
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,24 @@ 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` carries one, chosen as that field documents.
|
|
82
|
+
"""
|
|
78
83
|
|
|
79
84
|
vendor_name: str | None = None # pv/vendor-name
|
|
80
85
|
model: str | None = None # human designation (v1.0 info/model; flat pv/product-name)
|
|
81
|
-
nameplate_capacity_w: float | None = None
|
|
86
|
+
nameplate_capacity_w: float | None = None
|
|
87
|
+
"""The PV array's DC size as recorded at installation, in watts. Informational.
|
|
88
|
+
|
|
89
|
+
v1.0 `info/nominal-power`; flat `pv/nameplate-capacity`. A figure entered at
|
|
90
|
+
commissioning, never a measured or enforced limit: the inverter can produce
|
|
91
|
+
more or less than this, so a consumer must not treat it as a ceiling on PV
|
|
92
|
+
power or clamp a reading to it.
|
|
93
|
+
"""
|
|
94
|
+
|
|
82
95
|
feed_circuit_id: str | None = None # pv/feed (normalized circuit ID)
|
|
83
96
|
relative_position: str | None = None # pv/relative-position (IN_PANEL | UPSTREAM | DOWNSTREAM)
|
|
84
97
|
software_version: str | None = None
|
|
@@ -106,6 +119,31 @@ class SpanPVSnapshot:
|
|
|
106
119
|
way to say it.
|
|
107
120
|
"""
|
|
108
121
|
|
|
122
|
+
serial_number: str | None = None
|
|
123
|
+
"""`info/serial-number`, v1.0 only. Frequently unpublished, and that is normal.
|
|
124
|
+
|
|
125
|
+
An inverter's serial is often not recorded at commissioning, so `None` is
|
|
126
|
+
the ordinary case rather than a fault. Shown on a device card, never used as
|
|
127
|
+
an identity: see `node_id`.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
device_id: str | None = None
|
|
131
|
+
"""The inverter's id on the wire: the v1.0 device id, or the flat node id.
|
|
132
|
+
|
|
133
|
+
For addressing and diagnostics, never for identity. On a panel with more
|
|
134
|
+
than one inverter every PV device id changes at firmware r202639, the one
|
|
135
|
+
published before included.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
node_id: str | None = None
|
|
139
|
+
"""This inverter's key in `SpanPanelSnapshot.pv_inverters`; `None` on the empty snapshot.
|
|
140
|
+
|
|
141
|
+
Named after `SpanEvseSnapshot.node_id`, which plays the same role for
|
|
142
|
+
chargers. It is the feeding circuit's id when a circuit feeds the inverter,
|
|
143
|
+
because that id stays put when the inverter's own device id changes, and
|
|
144
|
+
`device_id` otherwise.
|
|
145
|
+
"""
|
|
146
|
+
|
|
109
147
|
|
|
110
148
|
@dataclass(frozen=True, slots=True)
|
|
111
149
|
class SpanMidSnapshot:
|
|
@@ -310,7 +348,13 @@ class SpanEvseSnapshot:
|
|
|
310
348
|
|
|
311
349
|
`None` means the charger declares no such property — `charge-limit.md` reads
|
|
312
350
|
that as "no adjustable charge-current ceiling; it charges at a fixed rate" —
|
|
313
|
-
or that it has not published a value yet
|
|
351
|
+
or that it has not published a value yet, or, from firmware r202639, that
|
|
352
|
+
no user has set a limit. That release publishes a value only once a user
|
|
353
|
+
sets one, leaving `charge_current_ceiling_a` as the limit in force; earlier
|
|
354
|
+
releases filled it with the ceiling on their own, and that retained value
|
|
355
|
+
can outlive the upgrade. A value equal to the ceiling therefore means the
|
|
356
|
+
same as `None`. Read `effective_charge_current_limit_a` for the limit the
|
|
357
|
+
charger is actually applying.
|
|
314
358
|
|
|
315
359
|
**Not `advertised_current_a`.** That is the current actually being offered
|
|
316
360
|
to the vehicle, which the capability defines as the `min()` of this, the
|
|
@@ -360,6 +404,20 @@ class SpanEvseSnapshot:
|
|
|
360
404
|
cases.
|
|
361
405
|
"""
|
|
362
406
|
|
|
407
|
+
@property
|
|
408
|
+
def effective_charge_current_limit_a(self) -> int | None:
|
|
409
|
+
"""The charge-current limit in force, in amps: the user's, else the ceiling.
|
|
410
|
+
|
|
411
|
+
`charge_current_limit_a` when one is published, otherwise
|
|
412
|
+
`charge_current_ceiling_a`, which is the limit from r202639 whenever no
|
|
413
|
+
user has set one. A stale retained limit equal to the ceiling resolves
|
|
414
|
+
to the same number either way, so no special case is needed for it.
|
|
415
|
+
`None` only when neither half is published.
|
|
416
|
+
"""
|
|
417
|
+
if self.charge_current_limit_a is not None:
|
|
418
|
+
return self.charge_current_limit_a
|
|
419
|
+
return self.charge_current_ceiling_a
|
|
420
|
+
|
|
363
421
|
|
|
364
422
|
@dataclass(frozen=True, slots=True)
|
|
365
423
|
class SpanBatterySnapshot:
|
|
@@ -379,23 +437,24 @@ class SpanBatterySnapshot:
|
|
|
379
437
|
nameplate_capacity_kwh: float | None = None # bess/nameplate-capacity (kWh)
|
|
380
438
|
connected: bool | None = None # bess/connected
|
|
381
439
|
|
|
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
|
|
440
|
+
# The BESS's own `meter/active-power`, v1.0 only. **Discharge-positive**:
|
|
387
441
|
# positive means power flowing *out of* the battery, which is discharging.
|
|
388
442
|
# 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
|
-
#
|
|
443
|
+
# it is deliberately NOT the into-the-device rule
|
|
444
|
+
# `SpanCircuitSnapshot.instant_power_w` follows. Measured against a producer
|
|
445
|
+
# in self-consumption with the grid at zero, where the direction cannot be
|
|
446
|
+
# argued.
|
|
447
|
+
#
|
|
448
|
+
# The wire frame changed under this field at firmware r202639: earlier
|
|
449
|
+
# releases publish the BESS meter charge-positive and the adapter negates it;
|
|
450
|
+
# r202639 and later publish it discharge-positive and it passes through. The
|
|
451
|
+
# field's own convention is the same on both.
|
|
393
452
|
#
|
|
394
453
|
# Distinct from `SpanPanelSnapshot.power_flow_battery`, which is the
|
|
395
454
|
# enclosure's own arbitrated flow figure, passed through untouched and
|
|
396
455
|
# charge-positive. The two describe the same physical power in opposite
|
|
397
456
|
# frames, so a consumer rendering both must negate one of them; this one is
|
|
398
|
-
# already
|
|
457
|
+
# already in the discharge-positive frame.
|
|
399
458
|
power_w: float | None = None # v2: bess meter/active-power (W), discharge-positive
|
|
400
459
|
|
|
401
460
|
# `status/communication-state`, v1.0 only: the BESS publisher's report of its
|
|
@@ -513,20 +572,27 @@ class DiscoveredMetadata(FieldMetadata):
|
|
|
513
572
|
|
|
514
573
|
@dataclass(frozen=True, slots=True)
|
|
515
574
|
class V2AuthResponse:
|
|
516
|
-
"""Response from POST /api/v2/auth/register.
|
|
575
|
+
"""Response from POST /api/v2/auth/register.
|
|
576
|
+
|
|
577
|
+
``ebus_broker_password`` and ``hop_passphrase`` are ``None`` when the panel
|
|
578
|
+
could not read its passphrase. From firmware r202639 that no longer fails
|
|
579
|
+
the registration: the access token is still issued and REST calls work, but
|
|
580
|
+
there is no broker password to connect with. Earlier firmware always
|
|
581
|
+
supplied both.
|
|
582
|
+
"""
|
|
517
583
|
|
|
518
584
|
access_token: str
|
|
519
585
|
token_type: str
|
|
520
586
|
iat_ms: int
|
|
521
587
|
ebus_broker_username: str
|
|
522
|
-
ebus_broker_password: str # Use this for MQTT, NOT hop_passphrase
|
|
588
|
+
ebus_broker_password: str | None # Use this for MQTT, NOT hop_passphrase
|
|
523
589
|
ebus_broker_host: str
|
|
524
590
|
ebus_broker_mqtts_port: int
|
|
525
591
|
ebus_broker_ws_port: int
|
|
526
592
|
ebus_broker_wss_port: int
|
|
527
593
|
hostname: str
|
|
528
594
|
serial_number: str
|
|
529
|
-
hop_passphrase: str # For REST auth; currently the same value as ebus_broker_password
|
|
595
|
+
hop_passphrase: str | None # For REST auth; currently the same value as ebus_broker_password
|
|
530
596
|
|
|
531
597
|
|
|
532
598
|
@dataclass(frozen=True, slots=True)
|
|
@@ -893,6 +959,10 @@ class ExtensionSubject:
|
|
|
893
959
|
The EVSE's `node_id` and the circuit's `circuit_id` -- the same keys
|
|
894
960
|
`snapshot.evse` and `snapshot.circuits` use, so a consumer holding the
|
|
895
961
|
snapshot resolves the subject with a lookup it already performs.
|
|
962
|
+
|
|
963
|
+
`pv` is a singleton while the panel publishes one inverter. With more than
|
|
964
|
+
one, each inverter's subject carries its `SpanPVSnapshot.node_id`, the key
|
|
965
|
+
`snapshot.pv_inverters` uses.
|
|
896
966
|
"""
|
|
897
967
|
|
|
898
968
|
|
|
@@ -1112,6 +1182,26 @@ class SpanPanelSnapshot:
|
|
|
1112
1182
|
circuits: dict[str, SpanCircuitSnapshot] = field(default_factory=dict)
|
|
1113
1183
|
battery: SpanBatterySnapshot = field(default_factory=SpanBatterySnapshot)
|
|
1114
1184
|
pv: SpanPVSnapshot = field(default_factory=SpanPVSnapshot)
|
|
1185
|
+
"""One PV inverter, or the empty snapshot when none is commissioned.
|
|
1186
|
+
|
|
1187
|
+
With a single inverter it is that inverter. With several it is the one whose
|
|
1188
|
+
feeding circuit occupies the lowest breaker space; an inverter with no
|
|
1189
|
+
feeding circuit ranks after every circuit-fed one, and remaining ties go to
|
|
1190
|
+
the lowest device id. Kept for consumers written before `pv_inverters`,
|
|
1191
|
+
which carries every inverter, this one included.
|
|
1192
|
+
"""
|
|
1193
|
+
pv_inverters: dict[str, SpanPVSnapshot] = field(default_factory=dict)
|
|
1194
|
+
"""Every commissioned PV inverter, keyed by `SpanPVSnapshot.node_id`.
|
|
1195
|
+
|
|
1196
|
+
The key is the feeding circuit's id where a circuit feeds the inverter, and
|
|
1197
|
+
the inverter's own device id otherwise. Preferring the circuit keeps the key
|
|
1198
|
+
still across firmware r202639, which changes every PV device id on a panel
|
|
1199
|
+
with more than one inverter but leaves circuit ids alone.
|
|
1200
|
+
|
|
1201
|
+
Empty when no inverter is commissioned, and from an adapter that predates
|
|
1202
|
+
the field; a consumer falls back to `pv` in that case. A defaulted snapshot
|
|
1203
|
+
field for the reason `adopted_devices` gives.
|
|
1204
|
+
"""
|
|
1115
1205
|
evse: dict[str, SpanEvseSnapshot] = field(default_factory=dict) # keyed by serial (see SpanEvseSnapshot.node_id)
|
|
1116
1206
|
mid: SpanMidSnapshot | None = None
|
|
1117
1207
|
"""The islanding authority, when the panel publishes one. v1.0 only.
|
|
@@ -667,11 +667,18 @@ class TestHomiePVMetadata:
|
|
|
667
667
|
assert snapshot.pv.nameplate_capacity_w == 3960.0
|
|
668
668
|
assert snapshot.pv.feed_circuit_id == "aabbccdd112233445566778899001122"
|
|
669
669
|
assert snapshot.pv.relative_position == "IN_PANEL"
|
|
670
|
+
assert snapshot.pv.device_id == "pv-0"
|
|
671
|
+
# Keyed by the feeding circuit, as schema_1 keys each inverter, so the
|
|
672
|
+
# one inverter flat publishes is also the whole of `pv_inverters`.
|
|
673
|
+
assert snapshot.pv.node_id == "aabbccdd112233445566778899001122"
|
|
674
|
+
assert snapshot.pv_inverters == {"aabbccdd112233445566778899001122": snapshot.pv}
|
|
670
675
|
|
|
671
676
|
def test_no_pv_node(self):
|
|
672
677
|
"""Without PV node, pv snapshot has None values."""
|
|
673
678
|
acc, consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}})
|
|
674
679
|
snapshot = consumer.build_snapshot()
|
|
680
|
+
assert snapshot.pv_inverters == {}
|
|
681
|
+
assert snapshot.pv.node_id is None
|
|
675
682
|
assert snapshot.pv.vendor_name is None
|
|
676
683
|
assert snapshot.pv.model is None
|
|
677
684
|
assert snapshot.pv.nameplate_capacity_w is None
|
|
@@ -694,6 +701,8 @@ class TestHomiePVMetadata:
|
|
|
694
701
|
assert snapshot.pv.nameplate_capacity_w is None
|
|
695
702
|
assert snapshot.pv.feed_circuit_id is None
|
|
696
703
|
assert snapshot.pv.relative_position is None
|
|
704
|
+
# No feeding circuit, so the node id is the only key there is.
|
|
705
|
+
assert snapshot.pv_inverters == {"pv-0": snapshot.pv}
|
|
697
706
|
|
|
698
707
|
|
|
699
708
|
# ---------------------------------------------------------------------------
|
|
@@ -161,6 +161,11 @@ EXPECTED_PUBLIC_API = {
|
|
|
161
161
|
# Added 2026-10-02 (3.5.0): a 403 from a reduced-privilege token. A subclass
|
|
162
162
|
# of SpanPanelAuthError, so every existing except clause keeps its meaning.
|
|
163
163
|
"SpanPanelInsufficientPrivilegeError",
|
|
164
|
+
# Added 2026-10-06 (3.6.0): registration reached a panel that cannot read its own
|
|
165
|
+
# passphrase. Additive, and a SpanPanelAPIError subclass rather than a
|
|
166
|
+
# SpanPanelAuthError, so no existing except clause starts telling a user
|
|
167
|
+
# their passphrase is wrong.
|
|
168
|
+
"SpanPanelPassphraseUnavailableError",
|
|
164
169
|
"SpanPanelServerError",
|
|
165
170
|
"SpanPanelStaleDataError",
|
|
166
171
|
# Added 2026-08-31 (3.4.0): a bootstrap REST call that failed *verification*
|