span-panel-api 3.4.1__tar.gz → 3.5.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.5.0}/CHANGELOG.md +32 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/PKG-INFO +10 -4
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/README.md +9 -3
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/pyproject.toml +1 -1
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/__init__.py +11 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/auth.py +98 -14
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/exceptions.py +10 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/models.py +9 -1
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/client.py +9 -1
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/connection.py +15 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/protocol.py +4 -2
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_detection_auth.py +75 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_https_transport.py +8 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_mqtt_connect_flow.py +89 -2
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_mqtt_homie.py +1 -1
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_public_api_unchanged.py +8 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_rest_transport_contract.py +3 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_adapter.py +6 -2
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_control_refusal.py +25 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_panel.py +25 -4
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/.gitignore +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/LICENSE +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/_ssl.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/conftest.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/panelbench_wire.json +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/reference_payloads/README.md +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/reference_payloads/bootstrap.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/reference_payloads/schema_one.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_adoption.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_ca_pinning.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_leaf_name_mismatch.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_packaging.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_plaintext_warning.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_fetch_transport_split.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_discovery.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_snapshot.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_ssl_context.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_v2_status_parser.py +0 -0
- {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/tls_fixtures.py +0 -0
|
@@ -7,6 +7,38 @@ 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.5.0]
|
|
11
|
+
|
|
12
|
+
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
|
|
13
|
+
failure rather than a connection failure.
|
|
14
|
+
|
|
15
|
+
**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
|
|
16
|
+
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
|
|
17
|
+
about a minute before treating it as wrong credentials.
|
|
18
|
+
|
|
19
|
+
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).
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **`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.
|
|
24
|
+
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.
|
|
25
|
+
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,
|
|
26
|
+
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.
|
|
27
|
+
- **`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
|
|
28
|
+
existing except clauses still catch it.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **`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`.
|
|
33
|
+
- **`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
|
|
34
|
+
declare settable.
|
|
35
|
+
- **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.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **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"
|
|
40
|
+
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.
|
|
41
|
+
|
|
10
42
|
## [3.4.1]
|
|
11
43
|
|
|
12
44
|
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.5.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
|
|
@@ -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")
|
|
@@ -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,7 @@ from .exceptions import (
|
|
|
26
27
|
SpanPanelCAChangedError,
|
|
27
28
|
SpanPanelConnectionError,
|
|
28
29
|
SpanPanelError,
|
|
30
|
+
SpanPanelInsufficientPrivilegeError,
|
|
29
31
|
SpanPanelSchemaVersionError,
|
|
30
32
|
SpanPanelServerError,
|
|
31
33
|
SpanPanelStaleDataError,
|
|
@@ -46,6 +48,7 @@ from .models import (
|
|
|
46
48
|
ExtensionSubject,
|
|
47
49
|
FieldMetadata,
|
|
48
50
|
HomieSchemaTypes,
|
|
51
|
+
PassphraseRotation,
|
|
49
52
|
SpanBatterySnapshot,
|
|
50
53
|
SpanCircuitSnapshot,
|
|
51
54
|
SpanEvseSnapshot,
|
|
@@ -172,6 +175,11 @@ __all__ = [ # noqa: RUF022
|
|
|
172
175
|
"register_fqdn",
|
|
173
176
|
"regenerate_passphrase",
|
|
174
177
|
"register_v2",
|
|
178
|
+
# Added 2026-10-02 (3.5.0): the rotation reports both new values, because
|
|
179
|
+
# it replaces the hop passphrase as well as the broker password. Additive --
|
|
180
|
+
# regenerate_passphrase keeps its str return.
|
|
181
|
+
"rotate_passphrase",
|
|
182
|
+
"PassphraseRotation",
|
|
175
183
|
# Transport
|
|
176
184
|
"MqttClientConfig",
|
|
177
185
|
"SpanMqttClient",
|
|
@@ -199,6 +207,9 @@ __all__ = [ # noqa: RUF022
|
|
|
199
207
|
"SpanPanelSchemaVersionError",
|
|
200
208
|
"SpanPanelConnectionError",
|
|
201
209
|
"SpanPanelError",
|
|
210
|
+
# Added 2026-10-02 (3.5.0): a 403 from a reduced-privilege token. A subclass
|
|
211
|
+
# of SpanPanelAuthError, so every existing except clause keeps its meaning.
|
|
212
|
+
"SpanPanelInsufficientPrivilegeError",
|
|
202
213
|
"SpanPanelServerError",
|
|
203
214
|
"SpanPanelStaleDataError",
|
|
204
215
|
# Added 2026-08-31 (3.4.0): a bootstrap REST call that failed verification
|
|
@@ -18,9 +18,9 @@ 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 SpanPanelAPIError, SpanPanelAuthError, SpanPanelServerError
|
|
23
|
-
from .models import HomieSchemaTypes, V2AuthResponse, V2HomieSchema, V2StatusInfo
|
|
21
|
+
from ._http import CA_CERT_PATH, V2_STATUS_PATH, _Reply, _request
|
|
22
|
+
from .exceptions import SpanPanelAPIError, SpanPanelAuthError, SpanPanelInsufficientPrivilegeError, SpanPanelServerError
|
|
23
|
+
from .models import HomieSchemaTypes, PassphraseRotation, V2AuthResponse, V2HomieSchema, V2StatusInfo
|
|
24
24
|
|
|
25
25
|
_LOGGER = logging.getLogger(__name__)
|
|
26
26
|
|
|
@@ -491,6 +491,64 @@ async def get_homie_schema(
|
|
|
491
491
|
)
|
|
492
492
|
|
|
493
493
|
|
|
494
|
+
async def rotate_passphrase(
|
|
495
|
+
host: str,
|
|
496
|
+
token: str,
|
|
497
|
+
timeout: float = 10.0,
|
|
498
|
+
port: int | None = None,
|
|
499
|
+
httpx_client: httpx.AsyncClient | None = None,
|
|
500
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
501
|
+
) -> PassphraseRotation:
|
|
502
|
+
"""Rotate the panel's passphrase, which is currently also its MQTT broker password.
|
|
503
|
+
|
|
504
|
+
One PUT replaces both: the hop passphrase that ``register_v2`` accepts and
|
|
505
|
+
the broker password, which the panel reports as two fields currently
|
|
506
|
+
carrying the same new value. Use ``ebus_broker_password`` for the broker.
|
|
507
|
+
Afterwards ``register_v2`` accepts only the new passphrase. Access tokens
|
|
508
|
+
already issued are not revoked.
|
|
509
|
+
|
|
510
|
+
The broker may not accept the new password the moment this call returns,
|
|
511
|
+
and the old one may keep working briefly. Reconnect with the new password
|
|
512
|
+
and never fall back to the old one. If the broker refuses it, retry with
|
|
513
|
+
backoff for up to about a minute, and include ``SpanPanelAuthError`` in the
|
|
514
|
+
retry: ``connect()`` raises it for a refused CONNACK, which is what a broker
|
|
515
|
+
that has not yet accepted the new password produces. If it is still refused
|
|
516
|
+
after that, rotate again and use the newly returned value. Do not rely on
|
|
517
|
+
the old password stopping, or on existing sessions being disconnected, at
|
|
518
|
+
any particular moment.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
host: IP address or hostname of the SPAN Panel
|
|
522
|
+
token: Valid JWT access token
|
|
523
|
+
timeout: Request timeout in seconds when ``httpx_client`` is None; ignored when injected.
|
|
524
|
+
port: Port of the panel bootstrap API. ``None`` means "unspecified" and takes
|
|
525
|
+
the scheme's default -- 80 without ``ssl_context``, 443 with one.
|
|
526
|
+
httpx_client: Optional shared ``httpx.AsyncClient``; not closed by this function.
|
|
527
|
+
Not used when ``ssl_context`` is supplied -- httpx fixes its trust store at
|
|
528
|
+
construction, so a pinned CA needs a client built for it. See ``_get_client``.
|
|
529
|
+
ssl_context: Trust anchor for the panel's HTTPS certificate. Supplying one moves
|
|
530
|
+
this call to ``https://``; ``None`` is byte-identical to 3.0.1.
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
The new broker password and the new hop passphrase.
|
|
534
|
+
|
|
535
|
+
Raises:
|
|
536
|
+
SpanPanelInsufficientPrivilegeError: HTTP 403; the token is reduced-privilege
|
|
537
|
+
SpanPanelAuthError: HTTP 401 (token invalid or stale) or 412 (no bearer token)
|
|
538
|
+
SpanPanelServerError: HTTP 5xx. After a 500 the outcome is unknown: the
|
|
539
|
+
passphrase may or may not have changed.
|
|
540
|
+
SpanPanelConnectionError: Cannot reach panel
|
|
541
|
+
SpanPanelTimeoutError: Request timed out
|
|
542
|
+
SpanPanelAPIError: Unexpected response
|
|
543
|
+
"""
|
|
544
|
+
reply = await _put_passphrase(host, token, timeout, port, httpx_client, ssl_context)
|
|
545
|
+
data = reply.json_object("ebusBrokerPassword", "hopPassphrase")
|
|
546
|
+
return PassphraseRotation(
|
|
547
|
+
ebus_broker_password=_str(data["ebusBrokerPassword"]),
|
|
548
|
+
hop_passphrase=_str(data["hopPassphrase"]),
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
494
552
|
async def regenerate_passphrase(
|
|
495
553
|
host: str,
|
|
496
554
|
token: str,
|
|
@@ -499,11 +557,13 @@ async def regenerate_passphrase(
|
|
|
499
557
|
httpx_client: httpx.AsyncClient | None = None,
|
|
500
558
|
ssl_context: ssl.SSLContext | None = None,
|
|
501
559
|
) -> str:
|
|
502
|
-
"""Rotate the
|
|
560
|
+
"""Rotate the panel's passphrase and return only the new broker password.
|
|
503
561
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
562
|
+
The same PUT as ``rotate_passphrase``, with the same effect: the hop
|
|
563
|
+
passphrase changes too, ``register_v2`` accepts only the new one, access
|
|
564
|
+
tokens already issued are not revoked, and the same reconnect advice
|
|
565
|
+
applies. Use ``rotate_passphrase`` to receive both values; this function is
|
|
566
|
+
kept for callers written against its ``str`` return.
|
|
507
567
|
|
|
508
568
|
Args:
|
|
509
569
|
host: IP address or hostname of the SPAN Panel
|
|
@@ -521,11 +581,21 @@ async def regenerate_passphrase(
|
|
|
521
581
|
New MQTT broker password
|
|
522
582
|
|
|
523
583
|
Raises:
|
|
524
|
-
|
|
525
|
-
SpanPanelConnectionError: Cannot reach panel
|
|
526
|
-
SpanPanelTimeoutError: Request timed out
|
|
527
|
-
SpanPanelAPIError: Unexpected response
|
|
584
|
+
Exactly what ``rotate_passphrase`` raises.
|
|
528
585
|
"""
|
|
586
|
+
reply = await _put_passphrase(host, token, timeout, port, httpx_client, ssl_context)
|
|
587
|
+
return _str(reply.json_object("ebusBrokerPassword")["ebusBrokerPassword"])
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
async def _put_passphrase(
|
|
591
|
+
host: str,
|
|
592
|
+
token: str,
|
|
593
|
+
timeout: float,
|
|
594
|
+
port: int | None,
|
|
595
|
+
httpx_client: httpx.AsyncClient | None,
|
|
596
|
+
ssl_context: ssl.SSLContext | None,
|
|
597
|
+
) -> _Reply:
|
|
598
|
+
"""Send the rotation PUT and raise for every status but 200."""
|
|
529
599
|
reply = await _request(
|
|
530
600
|
"PUT",
|
|
531
601
|
host,
|
|
@@ -537,13 +607,27 @@ async def regenerate_passphrase(
|
|
|
537
607
|
headers=_bearer(token),
|
|
538
608
|
)
|
|
539
609
|
|
|
540
|
-
if reply.status_code
|
|
610
|
+
if reply.status_code == 403:
|
|
611
|
+
raise SpanPanelInsufficientPrivilegeError(
|
|
612
|
+
f"Token lacks the privilege to rotate the passphrase (HTTP {reply.status_code})"
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
if reply.status_code in (401, 412):
|
|
541
616
|
raise SpanPanelAuthError(f"Authentication failed (HTTP {reply.status_code})")
|
|
542
617
|
|
|
618
|
+
if reply.status_code >= 500:
|
|
619
|
+
raise SpanPanelServerError(
|
|
620
|
+
f"Panel could not rotate the passphrase: HTTP {reply.status_code}",
|
|
621
|
+
status_code=reply.status_code,
|
|
622
|
+
)
|
|
623
|
+
|
|
543
624
|
if reply.status_code != 200:
|
|
544
|
-
raise SpanPanelAPIError(
|
|
625
|
+
raise SpanPanelAPIError(
|
|
626
|
+
f"Failed to regenerate passphrase: HTTP {reply.status_code}",
|
|
627
|
+
status_code=reply.status_code,
|
|
628
|
+
)
|
|
545
629
|
|
|
546
|
-
return
|
|
630
|
+
return reply
|
|
547
631
|
|
|
548
632
|
|
|
549
633
|
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
|
|
|
@@ -526,7 +526,15 @@ class V2AuthResponse:
|
|
|
526
526
|
ebus_broker_wss_port: int
|
|
527
527
|
hostname: str
|
|
528
528
|
serial_number: str
|
|
529
|
-
hop_passphrase: str # For REST auth
|
|
529
|
+
hop_passphrase: str # For REST auth; currently the same value as ebus_broker_password
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@dataclass(frozen=True, slots=True)
|
|
533
|
+
class PassphraseRotation:
|
|
534
|
+
"""Response from PUT /api/v2/auth/passphrase: both values, both new."""
|
|
535
|
+
|
|
536
|
+
ebus_broker_password: str = field(repr=False)
|
|
537
|
+
hop_passphrase: str = field(repr=False)
|
|
530
538
|
|
|
531
539
|
|
|
532
540
|
@dataclass(frozen=True, slots=True)
|
|
@@ -416,6 +416,7 @@ class SpanMqttClient:
|
|
|
416
416
|
5. Wait for $state==ready and $description parsed
|
|
417
417
|
|
|
418
418
|
Raises:
|
|
419
|
+
SpanPanelAuthError: The broker refused the credentials
|
|
419
420
|
SpanPanelConnectionError: Cannot connect or device not ready
|
|
420
421
|
SpanPanelTimeoutError: Connection or ready timed out
|
|
421
422
|
"""
|
|
@@ -865,6 +866,13 @@ class SpanMqttClient:
|
|
|
865
866
|
directly; v1.0 routes the command to `shed/asserted-islanding-state`,
|
|
866
867
|
whose enum is `NONE`/`ON_GRID`/`OFF_GRID`. Publishing `value` unchanged
|
|
867
868
|
would put a string outside that enum on the wire.
|
|
869
|
+
|
|
870
|
+
Refused, with nothing published, when the panel does not declare the
|
|
871
|
+
control settable, and under v1.0 for `NONE` and `UNKNOWN`, which the
|
|
872
|
+
panel would ignore. Under v1.0 the panel also ignores `ON_GRID` and
|
|
873
|
+
`OFF_GRID` while its link to the battery is healthy (the battery's
|
|
874
|
+
`status/communication-state` is `OK`), leaving the published value
|
|
875
|
+
unchanged.
|
|
868
876
|
"""
|
|
869
877
|
adapter = self._require_adapter()
|
|
870
878
|
target = adapter.set_dominant_power_source_target()
|
|
@@ -873,7 +881,7 @@ class SpanMqttClient:
|
|
|
873
881
|
device_id=self._serial_number,
|
|
874
882
|
value=value,
|
|
875
883
|
detail="no such control",
|
|
876
|
-
message="
|
|
884
|
+
message="Panel offers no settable dominant power source control",
|
|
877
885
|
)
|
|
878
886
|
payload = adapter.dominant_power_source_payload(value)
|
|
879
887
|
if payload is None:
|
|
@@ -26,6 +26,7 @@ from .._ssl import LeafNameMismatch, LeafProbe, build_panel_ssl_context, ca_fing
|
|
|
26
26
|
from ..auth import download_ca_cert
|
|
27
27
|
from ..exceptions import (
|
|
28
28
|
SpanPanelAPIError,
|
|
29
|
+
SpanPanelAuthError,
|
|
29
30
|
SpanPanelCAChangedError,
|
|
30
31
|
SpanPanelConnectionError,
|
|
31
32
|
SpanPanelError,
|
|
@@ -49,6 +50,11 @@ if TYPE_CHECKING:
|
|
|
49
50
|
|
|
50
51
|
_LOGGER = logging.getLogger(__name__)
|
|
51
52
|
|
|
53
|
+
# CONNACK refusals that mean the credentials were rejected: "Bad user name or
|
|
54
|
+
# password" (0x86) and "Not authorized" (0x87). paho reports the MQTT 3.1.1
|
|
55
|
+
# return codes 4 and 5 as these same MQTT 5 reason codes.
|
|
56
|
+
_CONNACK_AUTH_REFUSALS = frozenset({0x86, 0x87})
|
|
57
|
+
|
|
52
58
|
|
|
53
59
|
class AsyncMqttBridge:
|
|
54
60
|
"""Event-loop-driven paho-mqtt wrapper with async callback dispatch.
|
|
@@ -111,6 +117,8 @@ class AsyncMqttBridge:
|
|
|
111
117
|
self._connected = False
|
|
112
118
|
self._client: AsyncMQTTClient | None = None
|
|
113
119
|
self._connect_event: asyncio.Event | None = None
|
|
120
|
+
# Reason code of the most recent refused CONNACK; `connect()` reads it.
|
|
121
|
+
self._connack_refusal: ReasonCode | None = None
|
|
114
122
|
|
|
115
123
|
self._misc_timer: asyncio.TimerHandle | None = None
|
|
116
124
|
self._should_reconnect = False
|
|
@@ -420,6 +428,8 @@ class AsyncMqttBridge:
|
|
|
420
428
|
(blocking I/O), and waits for CONNACK.
|
|
421
429
|
|
|
422
430
|
Raises:
|
|
431
|
+
SpanPanelAuthError: The broker refused the username or password
|
|
432
|
+
(CONNACK "Bad user name or password" or "Not authorized").
|
|
423
433
|
SpanPanelConnectionError: Cannot connect to broker.
|
|
424
434
|
SpanPanelTimeoutError: Connection timed out.
|
|
425
435
|
SpanPanelCAChangedError: The panel is pinned and now advertises a
|
|
@@ -432,6 +442,7 @@ class AsyncMqttBridge:
|
|
|
432
442
|
self._loop = asyncio.get_running_loop()
|
|
433
443
|
|
|
434
444
|
self._connect_event = asyncio.Event()
|
|
445
|
+
self._connack_refusal = None
|
|
435
446
|
self._should_reconnect = True
|
|
436
447
|
|
|
437
448
|
_LOGGER.debug(
|
|
@@ -505,6 +516,9 @@ class AsyncMqttBridge:
|
|
|
505
516
|
raise SpanPanelTimeoutError(f"Timed out connecting to MQTT broker at {self._host}:{self._port}") from exc
|
|
506
517
|
|
|
507
518
|
if not self._connected:
|
|
519
|
+
refusal = self._connack_refusal
|
|
520
|
+
if refusal is not None and refusal.value in _CONNACK_AUTH_REFUSALS:
|
|
521
|
+
raise SpanPanelAuthError(f"MQTT broker at {self._host}:{self._port} refused the credentials: {refusal}")
|
|
508
522
|
raise SpanPanelConnectionError(f"MQTT connection failed to {self._host}:{self._port}")
|
|
509
523
|
|
|
510
524
|
self._initial_connect_done = True
|
|
@@ -763,6 +777,7 @@ class AsyncMqttBridge:
|
|
|
763
777
|
self._reconnect_task.cancel()
|
|
764
778
|
self._reconnect_task = None
|
|
765
779
|
else:
|
|
780
|
+
self._connack_refusal = reason_code
|
|
766
781
|
_LOGGER.warning("MQTT connection refused: %s", reason_code)
|
|
767
782
|
|
|
768
783
|
# Signal the asyncio connect() waiter
|
|
@@ -320,7 +320,7 @@ class SchemaAdapter(Protocol):
|
|
|
320
320
|
"""
|
|
321
321
|
|
|
322
322
|
def set_dominant_power_source_target(self) -> ControlTarget | None:
|
|
323
|
-
"""Where a dominant-power-source command goes, or None if the panel
|
|
323
|
+
"""Where a dominant-power-source command goes, or None if the panel offers no settable one."""
|
|
324
324
|
|
|
325
325
|
def set_evse_charge_limit_target(self, node_id: str) -> ControlTarget | None:
|
|
326
326
|
"""The target that writes one charger's charge-current limit, or None.
|
|
@@ -355,7 +355,9 @@ class SchemaAdapter(Protocol):
|
|
|
355
355
|
`NONE`/`ON_GRID`/`OFF_GRID`, so the value has to be mapped rather than
|
|
356
356
|
forwarded. Returning None means "no legal representation", and the
|
|
357
357
|
transport should refuse the command rather than publish a value the
|
|
358
|
-
panel will reject.
|
|
358
|
+
panel will reject. v1.0 returns None for `NONE` and `UNKNOWN`: the panel
|
|
359
|
+
ignores a written `NONE`, and an assertion clears itself once the
|
|
360
|
+
panel's link to the battery recovers.
|
|
359
361
|
"""
|
|
360
362
|
|
|
361
363
|
def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]:
|
|
@@ -11,9 +11,12 @@ from span_panel_api.exceptions import (
|
|
|
11
11
|
SpanPanelAPIError,
|
|
12
12
|
SpanPanelAuthError,
|
|
13
13
|
SpanPanelConnectionError,
|
|
14
|
+
SpanPanelInsufficientPrivilegeError,
|
|
15
|
+
SpanPanelServerError,
|
|
14
16
|
SpanPanelTimeoutError,
|
|
15
17
|
)
|
|
16
18
|
from span_panel_api.models import (
|
|
19
|
+
PassphraseRotation,
|
|
17
20
|
V2AuthResponse,
|
|
18
21
|
V2HomieSchema,
|
|
19
22
|
V2StatusInfo,
|
|
@@ -28,6 +31,7 @@ from span_panel_api.auth import (
|
|
|
28
31
|
regenerate_passphrase,
|
|
29
32
|
register_fqdn,
|
|
30
33
|
register_v2,
|
|
34
|
+
rotate_passphrase,
|
|
31
35
|
)
|
|
32
36
|
|
|
33
37
|
# ---------------------------------------------------------------------------
|
|
@@ -686,6 +690,77 @@ class TestRegeneratePassphrase:
|
|
|
686
690
|
await regenerate_passphrase("192.168.65.70", "")
|
|
687
691
|
|
|
688
692
|
|
|
693
|
+
def _put_client(response: httpx.Response) -> AsyncMock:
|
|
694
|
+
"""An injected client whose PUT returns `response`."""
|
|
695
|
+
client = AsyncMock(spec=httpx.AsyncClient)
|
|
696
|
+
client.put.return_value = response
|
|
697
|
+
return client
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
ROTATIONS = [
|
|
701
|
+
pytest.param(regenerate_passphrase, id="regenerate_passphrase"),
|
|
702
|
+
pytest.param(rotate_passphrase, id="rotate_passphrase"),
|
|
703
|
+
]
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
class TestRotatePassphrase:
|
|
707
|
+
@pytest.mark.asyncio
|
|
708
|
+
async def test_returns_both_new_values(self):
|
|
709
|
+
body = {"ebusBrokerPassword": "new-broker", "hopPassphrase": "new-hop"}
|
|
710
|
+
result = await rotate_passphrase("192.168.65.70", "jwt", httpx_client=_put_client(_mock_response(200, body)))
|
|
711
|
+
|
|
712
|
+
assert result == PassphraseRotation(ebus_broker_password="new-broker", hop_passphrase="new-hop")
|
|
713
|
+
|
|
714
|
+
@pytest.mark.asyncio
|
|
715
|
+
async def test_repr_omits_the_secrets(self):
|
|
716
|
+
body = {"ebusBrokerPassword": "new-broker", "hopPassphrase": "new-hop"}
|
|
717
|
+
result = await rotate_passphrase("192.168.65.70", "jwt", httpx_client=_put_client(_mock_response(200, body)))
|
|
718
|
+
|
|
719
|
+
assert "new-broker" not in repr(result)
|
|
720
|
+
assert "new-hop" not in repr(result)
|
|
721
|
+
|
|
722
|
+
@pytest.mark.asyncio
|
|
723
|
+
async def test_a_200_without_hop_passphrase_is_an_api_error(self):
|
|
724
|
+
response = _mock_response(200, {"ebusBrokerPassword": "new-broker"})
|
|
725
|
+
with pytest.raises(SpanPanelAPIError, match="hopPassphrase"):
|
|
726
|
+
await rotate_passphrase("192.168.65.70", "jwt", httpx_client=_put_client(response))
|
|
727
|
+
|
|
728
|
+
@pytest.mark.asyncio
|
|
729
|
+
@pytest.mark.parametrize("call", ROTATIONS)
|
|
730
|
+
async def test_403_is_insufficient_privilege(self, call):
|
|
731
|
+
with pytest.raises(SpanPanelInsufficientPrivilegeError, match="403") as caught:
|
|
732
|
+
await call("192.168.65.70", "door-token", httpx_client=_put_client(_mock_response(403)))
|
|
733
|
+
assert isinstance(caught.value, SpanPanelAuthError)
|
|
734
|
+
|
|
735
|
+
@pytest.mark.asyncio
|
|
736
|
+
@pytest.mark.parametrize("call", ROTATIONS)
|
|
737
|
+
@pytest.mark.parametrize("status", [401, 412])
|
|
738
|
+
async def test_401_and_412_are_auth_errors(self, call, status):
|
|
739
|
+
with pytest.raises(SpanPanelAuthError, match=str(status)) as caught:
|
|
740
|
+
await call("192.168.65.70", "jwt", httpx_client=_put_client(_mock_response(status)))
|
|
741
|
+
assert not isinstance(caught.value, SpanPanelInsufficientPrivilegeError)
|
|
742
|
+
|
|
743
|
+
@pytest.mark.asyncio
|
|
744
|
+
@pytest.mark.parametrize("call", ROTATIONS)
|
|
745
|
+
@pytest.mark.parametrize("status", [500, 503])
|
|
746
|
+
async def test_5xx_is_a_server_error_with_status(self, call, status):
|
|
747
|
+
response = _mock_response(status, text="secret-in-body")
|
|
748
|
+
with pytest.raises(SpanPanelServerError) as caught:
|
|
749
|
+
await call("192.168.65.70", "jwt", httpx_client=_put_client(response))
|
|
750
|
+
assert caught.value.status_code == status
|
|
751
|
+
assert "secret-in-body" not in str(caught.value)
|
|
752
|
+
|
|
753
|
+
@pytest.mark.asyncio
|
|
754
|
+
@pytest.mark.parametrize("call", ROTATIONS)
|
|
755
|
+
async def test_other_4xx_is_an_api_error_not_a_server_error(self, call):
|
|
756
|
+
response = _mock_response(404, text="secret-in-body")
|
|
757
|
+
with pytest.raises(SpanPanelAPIError) as caught:
|
|
758
|
+
await call("192.168.65.70", "jwt", httpx_client=_put_client(response))
|
|
759
|
+
assert not isinstance(caught.value, SpanPanelServerError)
|
|
760
|
+
assert caught.value.status_code == 404
|
|
761
|
+
assert "secret-in-body" not in str(caught.value)
|
|
762
|
+
|
|
763
|
+
|
|
689
764
|
# ===================================================================
|
|
690
765
|
# register_fqdn
|
|
691
766
|
# ===================================================================
|
|
@@ -27,6 +27,7 @@ from span_panel_api.auth import (
|
|
|
27
27
|
regenerate_passphrase,
|
|
28
28
|
register_fqdn,
|
|
29
29
|
register_v2,
|
|
30
|
+
rotate_passphrase,
|
|
30
31
|
)
|
|
31
32
|
from span_panel_api.detection import detect_api_version
|
|
32
33
|
from span_panel_api.exceptions import SpanPanelValidationError
|
|
@@ -153,6 +154,12 @@ class TestAuthCallsUseHttps:
|
|
|
153
154
|
_json_response({"ebusBrokerPassword": "new"}),
|
|
154
155
|
"/api/v2/auth/passphrase",
|
|
155
156
|
),
|
|
157
|
+
(
|
|
158
|
+
lambda ctx, c: rotate_passphrase(HOST, "tok", httpx_client=c, ssl_context=ctx),
|
|
159
|
+
"put",
|
|
160
|
+
_json_response({"ebusBrokerPassword": "new", "hopPassphrase": "new"}),
|
|
161
|
+
"/api/v2/auth/passphrase",
|
|
162
|
+
),
|
|
156
163
|
(
|
|
157
164
|
lambda ctx, c: register_fqdn(HOST, "tok", "panel.example", httpx_client=c, ssl_context=ctx),
|
|
158
165
|
"post",
|
|
@@ -188,6 +195,7 @@ class TestAuthCallsUseHttps:
|
|
|
188
195
|
"register_v2",
|
|
189
196
|
"get_homie_schema",
|
|
190
197
|
"regenerate_passphrase",
|
|
198
|
+
"rotate_passphrase",
|
|
191
199
|
"register_fqdn",
|
|
192
200
|
"get_fqdn",
|
|
193
201
|
"delete_fqdn",
|
|
@@ -11,10 +11,10 @@ import ssl
|
|
|
11
11
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
12
12
|
|
|
13
13
|
import pytest
|
|
14
|
-
from paho.mqtt.client import ConnectFlags, DisconnectFlags, MQTTMessage
|
|
14
|
+
from paho.mqtt.client import ConnackCode, ConnectFlags, DisconnectFlags, MQTTMessage, convert_connack_rc_to_reason_code
|
|
15
15
|
from paho.mqtt.reasoncodes import ReasonCode
|
|
16
16
|
|
|
17
|
-
from span_panel_api.exceptions import SpanPanelAPIError, SpanPanelConnectionError
|
|
17
|
+
from span_panel_api.exceptions import SpanPanelAPIError, SpanPanelAuthError, SpanPanelConnectionError
|
|
18
18
|
from span_panel_api.mqtt.client import SpanMqttClient
|
|
19
19
|
from span_panel_api.mqtt.connection import AsyncMqttBridge
|
|
20
20
|
from span_panel_api.mqtt.const import MQTT_FULL_REBUILD_AFTER_FAILURES, MQTT_RECONNECT_MIN_DELAY_S
|
|
@@ -141,6 +141,93 @@ class TestBridgeConnect:
|
|
|
141
141
|
mqtt_client_mock.disconnect.assert_called_once()
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
def _refuse_connack(mqtt_client_mock: MagicMock, reason_code: ReasonCode) -> None:
|
|
145
|
+
"""Make the mock broker answer CONNECT with a refused CONNACK."""
|
|
146
|
+
loop = asyncio.get_running_loop()
|
|
147
|
+
|
|
148
|
+
def _connect(**_kwargs: object) -> int:
|
|
149
|
+
loop.call_soon_threadsafe(
|
|
150
|
+
mqtt_client_mock.on_connect,
|
|
151
|
+
mqtt_client_mock,
|
|
152
|
+
None,
|
|
153
|
+
ConnectFlags(session_present=False),
|
|
154
|
+
reason_code,
|
|
155
|
+
None,
|
|
156
|
+
)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
mqtt_client_mock.connect.side_effect = _connect
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TestBridgeConnackRefusal:
|
|
163
|
+
@pytest.mark.asyncio
|
|
164
|
+
@pytest.mark.parametrize(
|
|
165
|
+
"reason_code",
|
|
166
|
+
[
|
|
167
|
+
convert_connack_rc_to_reason_code(ConnackCode.CONNACK_REFUSED_BAD_USERNAME_PASSWORD),
|
|
168
|
+
convert_connack_rc_to_reason_code(ConnackCode.CONNACK_REFUSED_NOT_AUTHORIZED),
|
|
169
|
+
ReasonCode(packetType=2, identifier=0x86),
|
|
170
|
+
ReasonCode(packetType=2, identifier=0x87),
|
|
171
|
+
],
|
|
172
|
+
ids=["v311-rc4", "v311-rc5", "v5-0x86", "v5-0x87"],
|
|
173
|
+
)
|
|
174
|
+
async def test_credential_refusal_raises_auth_error(self, mqtt_client_mock: MagicMock, reason_code: ReasonCode) -> None:
|
|
175
|
+
bridge = _make_bridge()
|
|
176
|
+
_refuse_connack(mqtt_client_mock, reason_code)
|
|
177
|
+
|
|
178
|
+
with pytest.raises(SpanPanelAuthError, match=str(reason_code)) as exc_info:
|
|
179
|
+
await bridge.connect()
|
|
180
|
+
|
|
181
|
+
assert not isinstance(exc_info.value, SpanPanelConnectionError)
|
|
182
|
+
assert bridge.is_connected() is False
|
|
183
|
+
assert bridge._initial_connect_done is False
|
|
184
|
+
|
|
185
|
+
@pytest.mark.asyncio
|
|
186
|
+
async def test_other_refusal_still_raises_connection_error(self, mqtt_client_mock: MagicMock) -> None:
|
|
187
|
+
bridge = _make_bridge()
|
|
188
|
+
_refuse_connack(
|
|
189
|
+
mqtt_client_mock,
|
|
190
|
+
convert_connack_rc_to_reason_code(ConnackCode.CONNACK_REFUSED_SERVER_UNAVAILABLE),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
with pytest.raises(SpanPanelConnectionError, match="MQTT connection failed"):
|
|
194
|
+
await bridge.connect()
|
|
195
|
+
|
|
196
|
+
@pytest.mark.asyncio
|
|
197
|
+
async def test_refusal_does_not_outlive_its_connect(self, mqtt_client_mock: MagicMock) -> None:
|
|
198
|
+
"""A credential refusal from an earlier attempt does not color a later one."""
|
|
199
|
+
bridge = _make_bridge()
|
|
200
|
+
_refuse_connack(mqtt_client_mock, ReasonCode(packetType=2, identifier=0x86))
|
|
201
|
+
with pytest.raises(SpanPanelAuthError):
|
|
202
|
+
await bridge.connect()
|
|
203
|
+
|
|
204
|
+
# The socket closes before any CONNACK arrives on the second attempt.
|
|
205
|
+
loop = asyncio.get_running_loop()
|
|
206
|
+
|
|
207
|
+
def _connect_then_drop(**_kwargs: object) -> int:
|
|
208
|
+
loop.call_soon_threadsafe(
|
|
209
|
+
mqtt_client_mock.on_disconnect,
|
|
210
|
+
mqtt_client_mock,
|
|
211
|
+
None,
|
|
212
|
+
DisconnectFlags(is_disconnect_packet_from_server=False),
|
|
213
|
+
ReasonCode(packetType=2, aName="Unspecified error"),
|
|
214
|
+
None,
|
|
215
|
+
)
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
mqtt_client_mock.connect.side_effect = _connect_then_drop
|
|
219
|
+
with pytest.raises(SpanPanelConnectionError, match="MQTT connection failed"):
|
|
220
|
+
await bridge.connect()
|
|
221
|
+
|
|
222
|
+
@pytest.mark.asyncio
|
|
223
|
+
async def test_span_client_connect_propagates_auth_error(self, mqtt_client_mock: MagicMock) -> None:
|
|
224
|
+
client = _make_span_client()
|
|
225
|
+
_refuse_connack(mqtt_client_mock, ReasonCode(packetType=2, aName="Bad user name or password"))
|
|
226
|
+
|
|
227
|
+
with pytest.raises(SpanPanelAuthError):
|
|
228
|
+
await client.connect()
|
|
229
|
+
|
|
230
|
+
|
|
144
231
|
# ---------------------------------------------------------------------------
|
|
145
232
|
# AsyncMqttBridge — subscribe / publish
|
|
146
233
|
# ---------------------------------------------------------------------------
|
|
@@ -1109,7 +1109,7 @@ class TestSpanMqttClientControl:
|
|
|
1109
1109
|
client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32))
|
|
1110
1110
|
|
|
1111
1111
|
# No description loaded — core node not found
|
|
1112
|
-
with pytest.raises(SpanPanelServerError, match="
|
|
1112
|
+
with pytest.raises(SpanPanelServerError, match="no settable dominant power source control"):
|
|
1113
1113
|
await client.set_dominant_power_source("GRID")
|
|
1114
1114
|
|
|
1115
1115
|
|
|
@@ -116,6 +116,11 @@ EXPECTED_PUBLIC_API = {
|
|
|
116
116
|
"register_fqdn",
|
|
117
117
|
"regenerate_passphrase",
|
|
118
118
|
"register_v2",
|
|
119
|
+
# Added 2026-10-02 (3.5.0): the rotation reports both new values, because
|
|
120
|
+
# it replaces the hop passphrase as well as the broker password. Additive --
|
|
121
|
+
# regenerate_passphrase keeps its str return.
|
|
122
|
+
"rotate_passphrase",
|
|
123
|
+
"PassphraseRotation",
|
|
119
124
|
# Transport
|
|
120
125
|
"MqttClientConfig",
|
|
121
126
|
"SpanMqttClient",
|
|
@@ -153,6 +158,9 @@ EXPECTED_PUBLIC_API = {
|
|
|
153
158
|
"SpanPanelSchemaVersionError",
|
|
154
159
|
"SpanPanelConnectionError",
|
|
155
160
|
"SpanPanelError",
|
|
161
|
+
# Added 2026-10-02 (3.5.0): a 403 from a reduced-privilege token. A subclass
|
|
162
|
+
# of SpanPanelAuthError, so every existing except clause keeps its meaning.
|
|
163
|
+
"SpanPanelInsufficientPrivilegeError",
|
|
156
164
|
"SpanPanelServerError",
|
|
157
165
|
"SpanPanelStaleDataError",
|
|
158
166
|
# Added 2026-08-31 (3.4.0): a bootstrap REST call that failed *verification*
|
|
@@ -35,6 +35,7 @@ from span_panel_api.auth import (
|
|
|
35
35
|
regenerate_passphrase,
|
|
36
36
|
register_fqdn,
|
|
37
37
|
register_v2,
|
|
38
|
+
rotate_passphrase,
|
|
38
39
|
)
|
|
39
40
|
from span_panel_api.detection import detect_api_version
|
|
40
41
|
from span_panel_api.exceptions import SpanPanelAPIError, SpanPanelConnectionError, SpanPanelError
|
|
@@ -48,6 +49,7 @@ CALLS: list[tuple[str, Callable[[httpx.AsyncClient], Awaitable[object]], str]] =
|
|
|
48
49
|
("register_v2", lambda c: register_v2(HOST, "home-assistant", "pass", httpx_client=c), "post"),
|
|
49
50
|
("download_ca_cert", lambda c: download_ca_cert(HOST, httpx_client=c), "get"),
|
|
50
51
|
("regenerate_passphrase", lambda c: regenerate_passphrase(HOST, "jwt", httpx_client=c), "put"),
|
|
52
|
+
("rotate_passphrase", lambda c: rotate_passphrase(HOST, "jwt", httpx_client=c), "put"),
|
|
51
53
|
("register_fqdn", lambda c: register_fqdn(HOST, "jwt", "panel.example.com", httpx_client=c), "post"),
|
|
52
54
|
("get_fqdn", lambda c: get_fqdn(HOST, "jwt", httpx_client=c), "get"),
|
|
53
55
|
("delete_fqdn", lambda c: delete_fqdn(HOST, "jwt", httpx_client=c), "delete"),
|
|
@@ -60,6 +62,7 @@ CALLS: list[tuple[str, Callable[[httpx.AsyncClient], Awaitable[object]], str]] =
|
|
|
60
62
|
DECODERS: list[tuple[str, Callable[[httpx.AsyncClient], Awaitable[object]], str]] = [
|
|
61
63
|
("register_v2", lambda c: register_v2(HOST, "home-assistant", "pass", httpx_client=c), "post"),
|
|
62
64
|
("regenerate_passphrase", lambda c: regenerate_passphrase(HOST, "jwt", httpx_client=c), "put"),
|
|
65
|
+
("rotate_passphrase", lambda c: rotate_passphrase(HOST, "jwt", httpx_client=c), "put"),
|
|
63
66
|
("get_fqdn", lambda c: get_fqdn(HOST, "jwt", httpx_client=c), "get"),
|
|
64
67
|
("get_v2_status", lambda c: get_v2_status(HOST, httpx_client=c), "get"),
|
|
65
68
|
]
|
|
@@ -418,8 +418,12 @@ def test_the_flat_vocabulary_is_translated_not_forwarded(adapter: SchemaOneAdapt
|
|
|
418
418
|
for off_grid in ("BATTERY", "PV", "GENERATOR"):
|
|
419
419
|
assert adapter.dominant_power_source_payload(off_grid) == "OFF_GRID", off_grid
|
|
420
420
|
|
|
421
|
-
|
|
422
|
-
|
|
421
|
+
|
|
422
|
+
def test_no_assertion_is_refused_rather_than_published(adapter: SchemaOneAdapter) -> None:
|
|
423
|
+
"""The panel ignores a written `NONE`, so publishing one would report a clear
|
|
424
|
+
that never happened. An assertion clears itself once the battery link recovers."""
|
|
425
|
+
for no_assertion in ("NONE", "UNKNOWN", "none"):
|
|
426
|
+
assert adapter.dominant_power_source_payload(no_assertion) is None, no_assertion
|
|
423
427
|
|
|
424
428
|
|
|
425
429
|
def test_an_unrecognised_value_is_refused_rather_than_guessed(adapter: SchemaOneAdapter) -> None:
|
|
@@ -459,6 +459,31 @@ def test_a_declared_priority_missing_from_its_node_is_undeclared_too(adapter: Sc
|
|
|
459
459
|
# ---------------------------------------------------------------------------
|
|
460
460
|
|
|
461
461
|
|
|
462
|
+
def test_the_capture_declares_the_islanding_assertion_settable(adapter: SchemaOneAdapter) -> None:
|
|
463
|
+
target = adapter.set_dominant_power_source_target()
|
|
464
|
+
|
|
465
|
+
assert target is not None
|
|
466
|
+
assert target.topic == f"ebus/5/{PANEL}/shed/asserted-islanding-state/set"
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@pytest.mark.parametrize("settable", [None, False])
|
|
470
|
+
def test_an_islanding_assertion_not_declared_settable_yields_no_target(settable: bool | None) -> None:
|
|
471
|
+
"""The same refusal the relay makes: absence of `$settable` authorizes nothing."""
|
|
472
|
+
adapter = _adapter(_redeclared(PANEL, "shed", "asserted-islanding-state", settable=settable))
|
|
473
|
+
|
|
474
|
+
assert adapter.set_dominant_power_source_target() is None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def test_a_panel_without_the_islanding_assertion_yields_no_target() -> None:
|
|
478
|
+
tree = _copy()
|
|
479
|
+
description = json.loads(tree[PANEL]["$description"])
|
|
480
|
+
del description["nodes"]["shed"]["properties"]["asserted-islanding-state"]
|
|
481
|
+
tree[PANEL]["$description"] = json.dumps(description)
|
|
482
|
+
tree[PANEL].pop("shed/asserted-islanding-state", None)
|
|
483
|
+
|
|
484
|
+
assert _adapter(tree).set_dominant_power_source_target() is None
|
|
485
|
+
|
|
486
|
+
|
|
462
487
|
def test_has_circuit_answers_for_the_circuits_and_nothing_else(adapter: SchemaOneAdapter) -> None:
|
|
463
488
|
"""Membership of the circuit set, not of the topology.
|
|
464
489
|
|
|
@@ -331,15 +331,28 @@ def _synthetic(device_id: str, state: str = "ready", **props: str) -> Discovered
|
|
|
331
331
|
|
|
332
332
|
|
|
333
333
|
def test_islanding_is_sensed_when_the_mid_is_ready() -> None:
|
|
334
|
-
"""Tier
|
|
334
|
+
"""Tier 2. With no assertion in force, the MID is the islanding authority."""
|
|
335
|
+
mid = _synthetic("mid", grid__islanding_state="OFF_GRID")
|
|
336
|
+
panel = _synthetic(PANEL, shed__asserted_islanding_state="NONE")
|
|
337
|
+
|
|
338
|
+
assert resolve_islanding_state(mid, panel) == "OFF_GRID"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_an_assertion_in_force_outranks_a_ready_mid() -> None:
|
|
342
|
+
"""Tier 1. The assertion is what the panel acts on, so it is the effective state.
|
|
343
|
+
|
|
344
|
+
Reading the MID first reported the sensed value while the panel was acting on the
|
|
345
|
+
user's assertion, which is the moment the two disagree and the one that matters.
|
|
346
|
+
"""
|
|
335
347
|
mid = _synthetic("mid", grid__islanding_state="OFF_GRID")
|
|
336
|
-
panel = _synthetic(PANEL, shed__asserted_islanding_state="ON_GRID")
|
|
337
348
|
|
|
338
|
-
|
|
349
|
+
for asserted in ("ON_GRID", "OFF_GRID"):
|
|
350
|
+
panel = _synthetic(PANEL, shed__asserted_islanding_state=asserted)
|
|
351
|
+
assert resolve_islanding_state(mid, panel) == asserted, asserted
|
|
339
352
|
|
|
340
353
|
|
|
341
354
|
def test_a_stale_mid_falls_back_to_the_users_assertion() -> None:
|
|
342
|
-
"""Tier
|
|
355
|
+
"""Tier 1 again, and the case the assertion control exists for.
|
|
343
356
|
|
|
344
357
|
When comms to the BESS or MID are lost and the grid returns, the user asserts the
|
|
345
358
|
grid is up so the BESS stops discharging. Declining to read it would wire the
|
|
@@ -359,6 +372,14 @@ def test_a_stale_mid_with_no_assertion_is_unknown_not_guessed() -> None:
|
|
|
359
372
|
assert resolve_islanding_state(mid, panel) is None
|
|
360
373
|
|
|
361
374
|
|
|
375
|
+
def test_an_assertion_outside_the_enum_is_not_an_answer() -> None:
|
|
376
|
+
"""Only `ON_GRID` and `OFF_GRID` are states; anything else falls through to the MID."""
|
|
377
|
+
mid = _synthetic("mid", grid__islanding_state="OFF_GRID")
|
|
378
|
+
panel = _synthetic(PANEL, shed__asserted_islanding_state="UNKNOWN")
|
|
379
|
+
|
|
380
|
+
assert resolve_islanding_state(mid, panel) == "OFF_GRID"
|
|
381
|
+
|
|
382
|
+
|
|
362
383
|
def test_no_mid_reads_grid_power_and_never_asserts_off_grid() -> None:
|
|
363
384
|
"""Tier 3, and the error worth keeping a test on.
|
|
364
385
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/panelbench_unvalued_by_both.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/simulation_fixtures/circuits.response.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|