span-panel-api 3.4.0__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.0 → span_panel_api-3.5.0}/CHANGELOG.md +42 -0
  2. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/PKG-INFO +10 -4
  3. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/README.md +9 -3
  4. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/pyproject.toml +1 -1
  5. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/__init__.py +11 -0
  6. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/_http.py +33 -16
  7. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/auth.py +98 -14
  8. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/exceptions.py +10 -0
  9. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/models.py +9 -1
  10. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/client.py +9 -1
  11. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/connection.py +15 -0
  12. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/protocol.py +4 -2
  13. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_detection_auth.py +75 -0
  14. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_https_transport.py +8 -0
  15. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_mqtt_connect_flow.py +89 -2
  16. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_mqtt_homie.py +1 -1
  17. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_plaintext_warning.py +50 -1
  18. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_public_api_unchanged.py +8 -0
  19. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_rest_transport_contract.py +3 -0
  20. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_adapter.py +6 -2
  21. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_control_refusal.py +25 -0
  22. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_panel.py +25 -4
  23. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/.gitignore +0 -0
  24. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/LICENSE +0 -0
  25. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/_ssl.py +0 -0
  26. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/adapters.py +0 -0
  27. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/const.py +0 -0
  28. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/detection.py +0 -0
  29. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/dispatch.py +0 -0
  30. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/factory.py +0 -0
  31. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/__init__.py +0 -0
  32. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  33. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/const.py +0 -0
  34. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/control.py +0 -0
  35. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/models.py +0 -0
  36. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/phase_validation.py +0 -0
  37. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/py.typed +0 -0
  38. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/schema_drift.py +0 -0
  39. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/conftest.py +0 -0
  40. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  41. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  42. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  43. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/flat_wire.json +0 -0
  44. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  45. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/panelbench_wire.json +0 -0
  46. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/v2/README.md +0 -0
  47. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/v2/status.json +0 -0
  48. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/reference_payloads/README.md +0 -0
  49. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/reference_payloads/__init__.py +0 -0
  50. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/reference_payloads/bootstrap.py +0 -0
  51. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/reference_payloads/schema_one.py +0 -0
  52. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  53. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  54. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  55. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/simulation_fixtures/status.response.txt +0 -0
  56. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_absent_readings_are_not_zero.py +0 -0
  57. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_accumulator.py +0 -0
  58. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_adapters_discovery.py +0 -0
  59. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_adopted_control.py +0 -0
  60. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_adoption.py +0 -0
  61. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_async_mqtt_client.py +0 -0
  62. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_auth_and_homie_helpers.py +0 -0
  63. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_auth_redaction.py +0 -0
  64. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_ca_pinning.py +0 -0
  65. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_catalog_divergence.py +0 -0
  66. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_control_interceptor.py +0 -0
  67. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_exceptions.py +0 -0
  68. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_factory_dispatch.py +0 -0
  69. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_field_metadata.py +0 -0
  70. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_leaf_name_mismatch.py +0 -0
  71. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_live_flat_differential.py +0 -0
  72. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_mqtt_bridge.py +0 -0
  73. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_mqtt_client_connection.py +0 -0
  74. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_mqtt_debounce.py +0 -0
  75. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_packaging.py +0 -0
  76. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_phase_validation_configs.py +0 -0
  77. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_phase_validation_errors.py +0 -0
  78. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_protocol_conformance.py +0 -0
  79. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_protocol_models.py +0 -0
  80. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_publish_outcome.py +0 -0
  81. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_redispatch_on_reconnect.py +0 -0
  82. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_reference_tree_values.py +0 -0
  83. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_fetch_transport_split.py +0 -0
  84. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_generation_cross_check.py +0 -0
  85. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_migration_delta.py +0 -0
  86. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_charge_limit.py +0 -0
  87. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_circuits.py +0 -0
  88. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_conformance.py +0 -0
  89. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_connection_health.py +0 -0
  90. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_devices.py +0 -0
  91. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_discovery.py +0 -0
  92. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_extension.py +0 -0
  93. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_pcs.py +0 -0
  94. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_service_entrance.py +0 -0
  95. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_shed_forecast.py +0 -0
  96. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_snapshot.py +0 -0
  97. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_transport.py +0 -0
  98. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_provenance.py +0 -0
  99. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_zero_adapter.py +0 -0
  100. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_shared_http_client.py +0 -0
  101. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_ssl_context.py +0 -0
  102. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_v2_status_parser.py +0 -0
  103. {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/tls_fixtures.py +0 -0
@@ -7,6 +7,48 @@ 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
+
42
+ ## [3.4.1]
43
+
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.
45
+
46
+ ### Changed
47
+
48
+ - **The status endpoint no longer emits the plaintext-transport warning**, for the CA download's reason from the other side: it is the detection probe made against devices nobody has configured, where no pin can exist and the only action is configuring the
49
+ panel — whose flow pins before any credential moves. It carries no credential in either direction, and it no longer spends the once-per-host warning slot, which it previously claimed first in every flow so that a genuinely credential-bearing call behind
50
+ it said nothing. Registration, passphrase rotation, and the schema fetch warn exactly as before.
51
+
10
52
  ## [3.4.0]
11
53
 
12
54
  A consumer that pinned the panel's CA could not put its schema fetches behind that pin, because the one port `SpanMqttClient` took served two transports with opposite security properties — the schema fetch, which should ride the pinned HTTPS transport, and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 3.4.0
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.0"
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
@@ -31,11 +31,14 @@ DEFAULT_HTTPS_PORT = 443
31
31
  #: The one bootstrap path two modules request: the detector probes it to decide
32
32
  #: whether the panel speaks v2 at all, and `get_v2_status` reads the same answer
33
33
  #: for a caller that already knows it does. Named here rather than spelled out in
34
- #: each, so the two cannot drift apart the way their parsers had.
34
+ #: each, so the two cannot drift apart the way their parsers had. Load-bearing
35
+ #: for `_warn_plaintext_transport`'s exemption: a request to this path is the
36
+ #: one the warning stays silent for, so a change here changes what warns.
35
37
  V2_STATUS_PATH = "/api/v2/status"
36
38
 
37
- #: The one bootstrap path exempt from the plaintext warning, named here because
38
- #: the transport is what grants the exemption. See `_warn_plaintext_transport`.
39
+ #: Exempt from the plaintext warning alongside `V2_STATUS_PATH`, named here
40
+ #: because the transport is what grants the exemption. See
41
+ #: `_warn_plaintext_transport`.
39
42
  CA_CERT_PATH = "/api/v2/certificate/ca"
40
43
 
41
44
  #: The verbs the bootstrap API uses. Spelled as a `Literal` rather than passed
@@ -174,19 +177,33 @@ def _reset_plaintext_warnings() -> None:
174
177
  def _warn_plaintext_transport(host: str, path: str, ssl_context: ssl.SSLContext | None) -> None:
175
178
  """Say out loud, once per panel, that its bootstrap traffic is not encrypted.
176
179
 
177
- **The CA download is exempt, and does not claim the once-per-host slot.**
180
+ **Two endpoints are exempt, and neither claims the once-per-host slot.**
178
181
  The warning exists so an operator can tell a security property is off when
179
- it could be on, and for that endpoint there is no "on": verifying the fetch
180
- of the anchor would require the anchor being fetched, an unverified-TLS
181
- wrapping is readable and forgeable by the same active on-path attacker, and
182
- the payload is a public certificate carrying no credential in either
183
- direction — its authenticity control is the leaf check callers run *after*
184
- the fetch. Each caller also states its own trust posture in its own voice:
185
- the bridge's unpinned warning, a config flow's fingerprint confirmation, a
186
- consumer's trust-on-first-use log. Warning here anyway named credentials the
187
- call never carries, which is the line issue span#264 reported. Not marking
188
- the host matters as much as not warning: a pinned consumer's diagnostic
189
- re-read must not spend the slot a genuinely plaintext call needs later.
182
+ it could be on, and for both there is no "on". The CA download fetches the
183
+ very anchor verification would need — an unverified-TLS wrapping is
184
+ readable and forgeable by the same active on-path attacker, the payload is
185
+ a public certificate, and its authenticity control is the leaf check
186
+ callers run *after* the fetch. The status endpoint is the detection probe:
187
+ most prominently the request discovery makes against a device nobody has
188
+ configured, where no pin can exist because trust-on-first-use has not
189
+ happened and the only action available is configuring the panel — whose
190
+ flow pins before any credential moves, with a person confirming the
191
+ fingerprint. Neither call carries a credential in either direction, and
192
+ warning on them named credentials they never carry: the CA download's line
193
+ is what issue span#264 reported, and the status probe's is what a merely
194
+ *advertising* unconfigured panel produced at every boot.
195
+
196
+ Two limits of the status exemption, stated rather than implied. The body
197
+ informs decisions — `proximityProven`, the serial an identity check reads —
198
+ and a plaintext answer is one anything on the path can write; the controls
199
+ for that are the panel's own registration gate and the pin the consumer's
200
+ flow acquires before a credential moves, not a log line. And a consumer
201
+ *can* probe a configured, pinned panel's status without its context; the
202
+ exemption means no warning will point that out, so a consumer owes every
203
+ probe of a configured host the entry's own transport. Not marking the host matters as
204
+ much as not warning — the probe runs first in every flow and a diagnostic
205
+ re-read runs on pinned entries, and neither may spend the slot a
206
+ credential-bearing call needs later.
190
207
 
191
208
  In the same voice as the MQTT bridge's unpinned-CA warning, and for the same
192
209
  reason: a security property that is off by default is only a decision if the
@@ -214,7 +231,7 @@ def _warn_plaintext_transport(host: str, path: str, ssl_context: ssl.SSLContext
214
231
  """
215
232
  if ssl_context is not None:
216
233
  return
217
- if path == CA_CERT_PATH:
234
+ if path in (CA_CERT_PATH, V2_STATUS_PATH):
218
235
  return
219
236
  if host in _warned_plaintext_hosts:
220
237
  return
@@ -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
  # ===================================================================