span-panel-api 3.4.1__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.4.1 → span_panel_api-3.6.0}/CHANGELOG.md +51 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/PKG-INFO +12 -6
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/README.md +9 -3
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/pyproject.toml +7 -3
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/__init__.py +17 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/auth.py +162 -20
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/exceptions.py +26 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/factory.py +10 -1
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/models.py +114 -16
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/client.py +9 -1
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/connection.py +15 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/protocol.py +4 -2
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_detection_auth.py +75 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_https_transport.py +8 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_mqtt_connect_flow.py +89 -2
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_mqtt_homie.py +10 -1
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_public_api_unchanged.py +13 -0
- span_panel_api-3.6.0/tests/test_register_passphrase_unavailable.py +148 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_rest_transport_contract.py +3 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_adapter.py +107 -6
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_charge_limit.py +37 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_connection_health.py +48 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_control_refusal.py +25 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_devices.py +128 -0
- {span_panel_api-3.4.1 → 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.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_panel.py +25 -4
- span_panel_api-3.6.0/tests/test_schema_one_snapshot.py +333 -0
- span_panel_api-3.4.1/tests/test_schema_one_snapshot.py +0 -142
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/.gitignore +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/LICENSE +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/_ssl.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/conftest.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/panelbench_wire.json +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/reference_payloads/README.md +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/reference_payloads/bootstrap.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/reference_payloads/schema_one.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_adoption.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_ca_pinning.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_leaf_name_mismatch.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_packaging.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_plaintext_warning.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_fetch_transport_split.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_ssl_context.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_v2_status_parser.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/tls_fixtures.py +0 -0
|
@@ -7,6 +7,57 @@ 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
|
+
|
|
29
|
+
## [3.5.0]
|
|
30
|
+
|
|
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
|
|
32
|
+
failure rather than a connection failure.
|
|
33
|
+
|
|
34
|
+
**Upgrade note.** `connect()` raises `SpanPanelAuthError`, where it raised `SpanPanelConnectionError`, when the broker refuses the credentials (CONNACK "Bad user name or password" or "Not authorized"). The two are siblings under `SpanPanelError`, so a
|
|
35
|
+
caller that caught `SpanPanelConnectionError` to handle a refused login must catch `SpanPanelAuthError` too. Right after a rotation the same error is also how a broker that has not yet accepted the new password answers, so retry it with backoff for up to
|
|
36
|
+
about a minute before treating it as wrong credentials.
|
|
37
|
+
|
|
38
|
+
Reported and fixed by [@dcj](https://github.com/dcj) in [#179](https://github.com/SpanPanel/span-panel-api/pull/179), from issue [#178](https://github.com/SpanPanel/span-panel-api/issues/178).
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- **`rotate_passphrase()` returns both new values** as a `PassphraseRotation` (`ebus_broker_password`, `hop_passphrase`), because a rotation replaces the hop passphrase as well as the broker password and `register_v2` afterwards accepts only the new one.
|
|
43
|
+
Exported with the result type. Access tokens already issued are not revoked. The broker may not accept the new password the moment the call returns, and the old one may keep working briefly: reconnect with the new one and never fall back to the old one.
|
|
44
|
+
If the broker refuses it, retry with backoff for up to about a minute, including `SpanPanelAuthError`, which `connect()` raises for a refused CONNACK and which is what a broker that has not yet accepted the new password produces. If it is still refused,
|
|
45
|
+
rotate again and use the newly returned value. Do not rely on the old password stopping, or on existing sessions being disconnected, at any particular moment.
|
|
46
|
+
- **`SpanPanelInsufficientPrivilegeError`** for a rotation refused with HTTP 403: the token is reduced-privilege (one obtained by proof of proximity), and registering with the passphrase yields a full-privilege token. A subclass of `SpanPanelAuthError`, so
|
|
47
|
+
existing except clauses still catch it.
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- **`regenerate_passphrase()` no longer claims the hop passphrase is unchanged.** It performs the same rotation and still returns only the new broker password as a `str`.
|
|
52
|
+
- **`set_dominant_power_source` refuses with "Panel offers no settable dominant power source control"** where it said "Core node not found in panel topology", since the parent/child schema has no core node and now also refuses a control the panel does not
|
|
53
|
+
declare settable.
|
|
54
|
+
- **Passphrase rotation raises `SpanPanelServerError` with `status_code` for a 5xx**, instead of a plain `SpanPanelAPIError`. After a 500 the outcome is unknown: the passphrase may or may not have changed.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- **A broker that refuses the credentials now raises `SpanPanelAuthError` from `connect()`**, instead of `SpanPanelConnectionError`, so a consumer can ask for new credentials rather than retry. This covers the CONNACK refusals "Bad user name or password"
|
|
59
|
+
and "Not authorized"; the error message carries the reason code. Every other refusal still raises `SpanPanelConnectionError`, and the reconnect loop still retries all of them.
|
|
60
|
+
|
|
10
61
|
## [3.4.1]
|
|
11
62
|
|
|
12
63
|
A panel that merely advertises itself on the network no longer produces the plaintext-transport warning when discovery probes it, closing the remaining way issue span#264's log line reached an operator who could do nothing about it.
|
|
@@ -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
|
|
@@ -476,7 +476,7 @@ Standalone async functions for v2-specific HTTP operations:
|
|
|
476
476
|
```python
|
|
477
477
|
from span_panel_api import (
|
|
478
478
|
register_v2, download_ca_cert, get_homie_schema,
|
|
479
|
-
|
|
479
|
+
rotate_passphrase, get_v2_status,
|
|
480
480
|
register_fqdn, get_fqdn, delete_fqdn,
|
|
481
481
|
)
|
|
482
482
|
|
|
@@ -493,8 +493,14 @@ schema = await get_homie_schema("192.168.1.100")
|
|
|
493
493
|
print(f"Panel size: {schema.panel_size} spaces")
|
|
494
494
|
print(f"Schema hash: {schema.types_schema_hash}")
|
|
495
495
|
|
|
496
|
-
# Rotate MQTT broker password
|
|
497
|
-
|
|
496
|
+
# Rotate the passphrase, which is currently also the MQTT broker password; both values change
|
|
497
|
+
# Access tokens already issued are not revoked
|
|
498
|
+
rotation = await rotate_passphrase("192.168.1.100", token=auth.access_token)
|
|
499
|
+
new_password = rotation.ebus_broker_password
|
|
500
|
+
# The broker may not accept new_password immediately, and the old one may keep
|
|
501
|
+
# working briefly. Retry a refused connect (including SpanPanelAuthError) with
|
|
502
|
+
# backoff for up to about a minute; if still refused, rotate again and use the
|
|
503
|
+
# newly returned value. Never fall back to the old password.
|
|
498
504
|
|
|
499
505
|
# Get panel status (unauthenticated)
|
|
500
506
|
status = await get_v2_status("192.168.1.100")
|
|
@@ -449,7 +449,7 @@ Standalone async functions for v2-specific HTTP operations:
|
|
|
449
449
|
```python
|
|
450
450
|
from span_panel_api import (
|
|
451
451
|
register_v2, download_ca_cert, get_homie_schema,
|
|
452
|
-
|
|
452
|
+
rotate_passphrase, get_v2_status,
|
|
453
453
|
register_fqdn, get_fqdn, delete_fqdn,
|
|
454
454
|
)
|
|
455
455
|
|
|
@@ -466,8 +466,14 @@ schema = await get_homie_schema("192.168.1.100")
|
|
|
466
466
|
print(f"Panel size: {schema.panel_size} spaces")
|
|
467
467
|
print(f"Schema hash: {schema.types_schema_hash}")
|
|
468
468
|
|
|
469
|
-
# Rotate MQTT broker password
|
|
470
|
-
|
|
469
|
+
# Rotate the passphrase, which is currently also the MQTT broker password; both values change
|
|
470
|
+
# Access tokens already issued are not revoked
|
|
471
|
+
rotation = await rotate_passphrase("192.168.1.100", token=auth.access_token)
|
|
472
|
+
new_password = rotation.ebus_broker_password
|
|
473
|
+
# The broker may not accept new_password immediately, and the old one may keep
|
|
474
|
+
# working briefly. Retry a refused connect (including SpanPanelAuthError) with
|
|
475
|
+
# backoff for up to about a minute; if still refused, rotate again and use the
|
|
476
|
+
# newly returned value. Never fall back to the old password.
|
|
471
477
|
|
|
472
478
|
# Get panel status (unauthenticated)
|
|
473
479
|
status = await get_v2_status("192.168.1.100")
|
|
@@ -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"
|
|
@@ -16,6 +16,7 @@ from .auth import (
|
|
|
16
16
|
regenerate_passphrase,
|
|
17
17
|
register_fqdn,
|
|
18
18
|
register_v2,
|
|
19
|
+
rotate_passphrase,
|
|
19
20
|
)
|
|
20
21
|
from .detection import DetectionResult, detect_api_version
|
|
21
22
|
from .exceptions import (
|
|
@@ -26,6 +27,8 @@ from .exceptions import (
|
|
|
26
27
|
SpanPanelCAChangedError,
|
|
27
28
|
SpanPanelConnectionError,
|
|
28
29
|
SpanPanelError,
|
|
30
|
+
SpanPanelInsufficientPrivilegeError,
|
|
31
|
+
SpanPanelPassphraseUnavailableError,
|
|
29
32
|
SpanPanelSchemaVersionError,
|
|
30
33
|
SpanPanelServerError,
|
|
31
34
|
SpanPanelStaleDataError,
|
|
@@ -46,6 +49,7 @@ from .models import (
|
|
|
46
49
|
ExtensionSubject,
|
|
47
50
|
FieldMetadata,
|
|
48
51
|
HomieSchemaTypes,
|
|
52
|
+
PassphraseRotation,
|
|
49
53
|
SpanBatterySnapshot,
|
|
50
54
|
SpanCircuitSnapshot,
|
|
51
55
|
SpanEvseSnapshot,
|
|
@@ -172,6 +176,11 @@ __all__ = [ # noqa: RUF022
|
|
|
172
176
|
"register_fqdn",
|
|
173
177
|
"regenerate_passphrase",
|
|
174
178
|
"register_v2",
|
|
179
|
+
# Added 2026-10-02 (3.5.0): the rotation reports both new values, because
|
|
180
|
+
# it replaces the hop passphrase as well as the broker password. Additive --
|
|
181
|
+
# regenerate_passphrase keeps its str return.
|
|
182
|
+
"rotate_passphrase",
|
|
183
|
+
"PassphraseRotation",
|
|
175
184
|
# Transport
|
|
176
185
|
"MqttClientConfig",
|
|
177
186
|
"SpanMqttClient",
|
|
@@ -199,6 +208,14 @@ __all__ = [ # noqa: RUF022
|
|
|
199
208
|
"SpanPanelSchemaVersionError",
|
|
200
209
|
"SpanPanelConnectionError",
|
|
201
210
|
"SpanPanelError",
|
|
211
|
+
# Added 2026-10-02 (3.5.0): a 403 from a reduced-privilege token. A subclass
|
|
212
|
+
# of SpanPanelAuthError, so every existing except clause keeps its meaning.
|
|
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",
|
|
202
219
|
"SpanPanelServerError",
|
|
203
220
|
"SpanPanelStaleDataError",
|
|
204
221
|
# Added 2026-08-31 (3.4.0): a bootstrap REST call that failed verification
|
|
@@ -18,9 +18,15 @@ import uuid
|
|
|
18
18
|
|
|
19
19
|
import httpx
|
|
20
20
|
|
|
21
|
-
from ._http import CA_CERT_PATH, V2_STATUS_PATH, _request
|
|
22
|
-
from .exceptions import
|
|
23
|
-
|
|
21
|
+
from ._http import CA_CERT_PATH, V2_STATUS_PATH, _Reply, _request
|
|
22
|
+
from .exceptions import (
|
|
23
|
+
SpanPanelAPIError,
|
|
24
|
+
SpanPanelAuthError,
|
|
25
|
+
SpanPanelInsufficientPrivilegeError,
|
|
26
|
+
SpanPanelPassphraseUnavailableError,
|
|
27
|
+
SpanPanelServerError,
|
|
28
|
+
)
|
|
29
|
+
from .models import HomieSchemaTypes, PassphraseRotation, V2AuthResponse, V2HomieSchema, V2StatusInfo
|
|
24
30
|
|
|
25
31
|
_LOGGER = logging.getLogger(__name__)
|
|
26
32
|
|
|
@@ -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
|
|
|
@@ -491,6 +549,64 @@ async def get_homie_schema(
|
|
|
491
549
|
)
|
|
492
550
|
|
|
493
551
|
|
|
552
|
+
async def rotate_passphrase(
|
|
553
|
+
host: str,
|
|
554
|
+
token: str,
|
|
555
|
+
timeout: float = 10.0,
|
|
556
|
+
port: int | None = None,
|
|
557
|
+
httpx_client: httpx.AsyncClient | None = None,
|
|
558
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
559
|
+
) -> PassphraseRotation:
|
|
560
|
+
"""Rotate the panel's passphrase, which is currently also its MQTT broker password.
|
|
561
|
+
|
|
562
|
+
One PUT replaces both: the hop passphrase that ``register_v2`` accepts and
|
|
563
|
+
the broker password, which the panel reports as two fields currently
|
|
564
|
+
carrying the same new value. Use ``ebus_broker_password`` for the broker.
|
|
565
|
+
Afterwards ``register_v2`` accepts only the new passphrase. Access tokens
|
|
566
|
+
already issued are not revoked.
|
|
567
|
+
|
|
568
|
+
The broker may not accept the new password the moment this call returns,
|
|
569
|
+
and the old one may keep working briefly. Reconnect with the new password
|
|
570
|
+
and never fall back to the old one. If the broker refuses it, retry with
|
|
571
|
+
backoff for up to about a minute, and include ``SpanPanelAuthError`` in the
|
|
572
|
+
retry: ``connect()`` raises it for a refused CONNACK, which is what a broker
|
|
573
|
+
that has not yet accepted the new password produces. If it is still refused
|
|
574
|
+
after that, rotate again and use the newly returned value. Do not rely on
|
|
575
|
+
the old password stopping, or on existing sessions being disconnected, at
|
|
576
|
+
any particular moment.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
host: IP address or hostname of the SPAN Panel
|
|
580
|
+
token: Valid JWT access token
|
|
581
|
+
timeout: Request timeout in seconds when ``httpx_client`` is None; ignored when injected.
|
|
582
|
+
port: Port of the panel bootstrap API. ``None`` means "unspecified" and takes
|
|
583
|
+
the scheme's default -- 80 without ``ssl_context``, 443 with one.
|
|
584
|
+
httpx_client: Optional shared ``httpx.AsyncClient``; not closed by this function.
|
|
585
|
+
Not used when ``ssl_context`` is supplied -- httpx fixes its trust store at
|
|
586
|
+
construction, so a pinned CA needs a client built for it. See ``_get_client``.
|
|
587
|
+
ssl_context: Trust anchor for the panel's HTTPS certificate. Supplying one moves
|
|
588
|
+
this call to ``https://``; ``None`` is byte-identical to 3.0.1.
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
The new broker password and the new hop passphrase.
|
|
592
|
+
|
|
593
|
+
Raises:
|
|
594
|
+
SpanPanelInsufficientPrivilegeError: HTTP 403; the token is reduced-privilege
|
|
595
|
+
SpanPanelAuthError: HTTP 401 (token invalid or stale) or 412 (no bearer token)
|
|
596
|
+
SpanPanelServerError: HTTP 5xx. After a 500 the outcome is unknown: the
|
|
597
|
+
passphrase may or may not have changed.
|
|
598
|
+
SpanPanelConnectionError: Cannot reach panel
|
|
599
|
+
SpanPanelTimeoutError: Request timed out
|
|
600
|
+
SpanPanelAPIError: Unexpected response
|
|
601
|
+
"""
|
|
602
|
+
reply = await _put_passphrase(host, token, timeout, port, httpx_client, ssl_context)
|
|
603
|
+
data = reply.json_object("ebusBrokerPassword", "hopPassphrase")
|
|
604
|
+
return PassphraseRotation(
|
|
605
|
+
ebus_broker_password=_str(data["ebusBrokerPassword"]),
|
|
606
|
+
hop_passphrase=_str(data["hopPassphrase"]),
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
494
610
|
async def regenerate_passphrase(
|
|
495
611
|
host: str,
|
|
496
612
|
token: str,
|
|
@@ -499,11 +615,13 @@ async def regenerate_passphrase(
|
|
|
499
615
|
httpx_client: httpx.AsyncClient | None = None,
|
|
500
616
|
ssl_context: ssl.SSLContext | None = None,
|
|
501
617
|
) -> str:
|
|
502
|
-
"""Rotate the
|
|
618
|
+
"""Rotate the panel's passphrase and return only the new broker password.
|
|
503
619
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
620
|
+
The same PUT as ``rotate_passphrase``, with the same effect: the hop
|
|
621
|
+
passphrase changes too, ``register_v2`` accepts only the new one, access
|
|
622
|
+
tokens already issued are not revoked, and the same reconnect advice
|
|
623
|
+
applies. Use ``rotate_passphrase`` to receive both values; this function is
|
|
624
|
+
kept for callers written against its ``str`` return.
|
|
507
625
|
|
|
508
626
|
Args:
|
|
509
627
|
host: IP address or hostname of the SPAN Panel
|
|
@@ -521,11 +639,21 @@ async def regenerate_passphrase(
|
|
|
521
639
|
New MQTT broker password
|
|
522
640
|
|
|
523
641
|
Raises:
|
|
524
|
-
|
|
525
|
-
SpanPanelConnectionError: Cannot reach panel
|
|
526
|
-
SpanPanelTimeoutError: Request timed out
|
|
527
|
-
SpanPanelAPIError: Unexpected response
|
|
642
|
+
Exactly what ``rotate_passphrase`` raises.
|
|
528
643
|
"""
|
|
644
|
+
reply = await _put_passphrase(host, token, timeout, port, httpx_client, ssl_context)
|
|
645
|
+
return _str(reply.json_object("ebusBrokerPassword")["ebusBrokerPassword"])
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
async def _put_passphrase(
|
|
649
|
+
host: str,
|
|
650
|
+
token: str,
|
|
651
|
+
timeout: float,
|
|
652
|
+
port: int | None,
|
|
653
|
+
httpx_client: httpx.AsyncClient | None,
|
|
654
|
+
ssl_context: ssl.SSLContext | None,
|
|
655
|
+
) -> _Reply:
|
|
656
|
+
"""Send the rotation PUT and raise for every status but 200."""
|
|
529
657
|
reply = await _request(
|
|
530
658
|
"PUT",
|
|
531
659
|
host,
|
|
@@ -537,13 +665,27 @@ async def regenerate_passphrase(
|
|
|
537
665
|
headers=_bearer(token),
|
|
538
666
|
)
|
|
539
667
|
|
|
540
|
-
if reply.status_code
|
|
668
|
+
if reply.status_code == 403:
|
|
669
|
+
raise SpanPanelInsufficientPrivilegeError(
|
|
670
|
+
f"Token lacks the privilege to rotate the passphrase (HTTP {reply.status_code})"
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
if reply.status_code in (401, 412):
|
|
541
674
|
raise SpanPanelAuthError(f"Authentication failed (HTTP {reply.status_code})")
|
|
542
675
|
|
|
676
|
+
if reply.status_code >= 500:
|
|
677
|
+
raise SpanPanelServerError(
|
|
678
|
+
f"Panel could not rotate the passphrase: HTTP {reply.status_code}",
|
|
679
|
+
status_code=reply.status_code,
|
|
680
|
+
)
|
|
681
|
+
|
|
543
682
|
if reply.status_code != 200:
|
|
544
|
-
raise SpanPanelAPIError(
|
|
683
|
+
raise SpanPanelAPIError(
|
|
684
|
+
f"Failed to regenerate passphrase: HTTP {reply.status_code}",
|
|
685
|
+
status_code=reply.status_code,
|
|
686
|
+
)
|
|
545
687
|
|
|
546
|
-
return
|
|
688
|
+
return reply
|
|
547
689
|
|
|
548
690
|
|
|
549
691
|
async def register_fqdn(
|
|
@@ -9,6 +9,16 @@ class SpanPanelAuthError(SpanPanelError):
|
|
|
9
9
|
"""Authentication failed."""
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
class SpanPanelInsufficientPrivilegeError(SpanPanelAuthError):
|
|
13
|
+
"""The token is valid but reduced-privilege, so the panel refused the call (HTTP 403).
|
|
14
|
+
|
|
15
|
+
A token obtained by proof of proximity (the door bypass) carries reduced
|
|
16
|
+
privilege. Registering with the panel's passphrase yields a full-privilege
|
|
17
|
+
token. A subclass of `SpanPanelAuthError`, so an existing except clause
|
|
18
|
+
keeps catching it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
12
22
|
class SpanPanelConnectionError(SpanPanelError):
|
|
13
23
|
"""Connection to SPAN panel failed."""
|
|
14
24
|
|
|
@@ -58,6 +68,22 @@ class SpanPanelServerError(SpanPanelAPIError):
|
|
|
58
68
|
"""
|
|
59
69
|
|
|
60
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
|
+
|
|
61
87
|
class SpanPanelCAChangedError(SpanPanelError):
|
|
62
88
|
"""The panel is presenting a certificate chain from a different CA than the pin.
|
|
63
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,
|