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.
Files changed (103) hide show
  1. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/CHANGELOG.md +32 -0
  2. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/PKG-INFO +10 -4
  3. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/README.md +9 -3
  4. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/pyproject.toml +1 -1
  5. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/__init__.py +11 -0
  6. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/auth.py +98 -14
  7. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/exceptions.py +10 -0
  8. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/models.py +9 -1
  9. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/client.py +9 -1
  10. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/connection.py +15 -0
  11. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/protocol.py +4 -2
  12. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_detection_auth.py +75 -0
  13. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_https_transport.py +8 -0
  14. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_mqtt_connect_flow.py +89 -2
  15. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_mqtt_homie.py +1 -1
  16. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_public_api_unchanged.py +8 -0
  17. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_rest_transport_contract.py +3 -0
  18. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_adapter.py +6 -2
  19. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_control_refusal.py +25 -0
  20. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_panel.py +25 -4
  21. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/.gitignore +0 -0
  22. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/LICENSE +0 -0
  23. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/_http.py +0 -0
  24. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/_ssl.py +0 -0
  25. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/adapters.py +0 -0
  26. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/const.py +0 -0
  27. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/detection.py +0 -0
  28. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/dispatch.py +0 -0
  29. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/factory.py +0 -0
  30. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/__init__.py +0 -0
  31. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  32. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/const.py +0 -0
  33. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/control.py +0 -0
  34. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/models.py +0 -0
  35. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/phase_validation.py +0 -0
  36. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/py.typed +0 -0
  37. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/src/span_panel_api/schema_drift.py +0 -0
  38. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/conftest.py +0 -0
  39. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  40. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  41. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  42. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/flat_wire.json +0 -0
  43. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  44. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/panelbench_wire.json +0 -0
  45. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/v2/README.md +0 -0
  46. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/fixtures/v2/status.json +0 -0
  47. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/reference_payloads/README.md +0 -0
  48. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/reference_payloads/__init__.py +0 -0
  49. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/reference_payloads/bootstrap.py +0 -0
  50. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/reference_payloads/schema_one.py +0 -0
  51. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  52. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  53. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  54. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/simulation_fixtures/status.response.txt +0 -0
  55. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_absent_readings_are_not_zero.py +0 -0
  56. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_accumulator.py +0 -0
  57. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_adapters_discovery.py +0 -0
  58. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_adopted_control.py +0 -0
  59. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_adoption.py +0 -0
  60. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_async_mqtt_client.py +0 -0
  61. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_auth_and_homie_helpers.py +0 -0
  62. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_auth_redaction.py +0 -0
  63. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_ca_pinning.py +0 -0
  64. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_catalog_divergence.py +0 -0
  65. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_control_interceptor.py +0 -0
  66. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_exceptions.py +0 -0
  67. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_factory_dispatch.py +0 -0
  68. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_field_metadata.py +0 -0
  69. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_leaf_name_mismatch.py +0 -0
  70. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_live_flat_differential.py +0 -0
  71. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_mqtt_bridge.py +0 -0
  72. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_mqtt_client_connection.py +0 -0
  73. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_mqtt_debounce.py +0 -0
  74. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_packaging.py +0 -0
  75. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_phase_validation_configs.py +0 -0
  76. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_phase_validation_errors.py +0 -0
  77. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_plaintext_warning.py +0 -0
  78. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_protocol_conformance.py +0 -0
  79. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_protocol_models.py +0 -0
  80. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_publish_outcome.py +0 -0
  81. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_redispatch_on_reconnect.py +0 -0
  82. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_reference_tree_values.py +0 -0
  83. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_fetch_transport_split.py +0 -0
  84. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_generation_cross_check.py +0 -0
  85. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_migration_delta.py +0 -0
  86. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_charge_limit.py +0 -0
  87. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_circuits.py +0 -0
  88. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_conformance.py +0 -0
  89. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_connection_health.py +0 -0
  90. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_devices.py +0 -0
  91. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_discovery.py +0 -0
  92. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_extension.py +0 -0
  93. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_pcs.py +0 -0
  94. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_service_entrance.py +0 -0
  95. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_shed_forecast.py +0 -0
  96. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_snapshot.py +0 -0
  97. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_one_transport.py +0 -0
  98. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_provenance.py +0 -0
  99. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_schema_zero_adapter.py +0 -0
  100. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_shared_http_client.py +0 -0
  101. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_ssl_context.py +0 -0
  102. {span_panel_api-3.4.1 → span_panel_api-3.5.0}/tests/test_v2_status_parser.py +0 -0
  103. {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.4.1
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
- regenerate_passphrase, get_v2_status,
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 (invalidates previous password)
497
- new_password = await regenerate_passphrase("192.168.1.100", token=auth.access_token)
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
- regenerate_passphrase, get_v2_status,
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 (invalidates previous password)
470
- new_password = await regenerate_passphrase("192.168.1.100", token=auth.access_token)
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.4.1"
3
+ version = "3.5.0"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -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 MQTT broker password on the SPAN Panel.
560
+ """Rotate the panel's passphrase and return only the new broker password.
503
561
 
504
- After this call, the previous broker password is invalidated.
505
- The new broker password is returned. Note: the hop_passphrase
506
- (used for REST auth) is NOT changed by this operation.
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
- SpanPanelAuthError: Token invalid or expired
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 in (401, 403, 412):
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(f"Failed to regenerate passphrase: HTTP {reply.status_code}")
625
+ raise SpanPanelAPIError(
626
+ f"Failed to regenerate passphrase: HTTP {reply.status_code}",
627
+ status_code=reply.status_code,
628
+ )
545
629
 
546
- return _str(reply.json_object("ebusBrokerPassword")["ebusBrokerPassword"])
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 only; will diverge from broker password
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="Core node not found in panel topology",
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 has no such control."""
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="Core node not found"):
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
- for no_assertion in ("NONE", "UNKNOWN"):
422
- assert adapter.dominant_power_source_payload(no_assertion) == "NONE", no_assertion
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 1. The MID is the islanding authority, so its answer wins outright."""
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
- assert resolve_islanding_state(mid, panel) == "OFF_GRID", "a ready MID outranks the user's assertion"
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 2, and the case the assertion control exists for.
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