span-panel-api 3.4.1__tar.gz → 3.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/CHANGELOG.md +51 -0
  2. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/PKG-INFO +12 -6
  3. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/README.md +9 -3
  4. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/pyproject.toml +7 -3
  5. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/__init__.py +17 -0
  6. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/auth.py +162 -20
  7. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/exceptions.py +26 -0
  8. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/factory.py +10 -1
  9. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/models.py +114 -16
  10. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/client.py +9 -1
  11. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/connection.py +15 -0
  12. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/protocol.py +4 -2
  13. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_detection_auth.py +75 -0
  14. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_https_transport.py +8 -0
  15. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_mqtt_connect_flow.py +89 -2
  16. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_mqtt_homie.py +10 -1
  17. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_public_api_unchanged.py +13 -0
  18. span_panel_api-3.6.0/tests/test_register_passphrase_unavailable.py +148 -0
  19. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_rest_transport_contract.py +3 -0
  20. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_adapter.py +107 -6
  21. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_charge_limit.py +37 -0
  22. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_connection_health.py +48 -0
  23. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_control_refusal.py +25 -0
  24. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_devices.py +128 -0
  25. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_discovery.py +5 -4
  26. span_panel_api-3.6.0/tests/test_schema_one_firmware.py +52 -0
  27. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_panel.py +25 -4
  28. span_panel_api-3.6.0/tests/test_schema_one_snapshot.py +333 -0
  29. span_panel_api-3.4.1/tests/test_schema_one_snapshot.py +0 -142
  30. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/.gitignore +0 -0
  31. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/LICENSE +0 -0
  32. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/_http.py +0 -0
  33. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/_ssl.py +0 -0
  34. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/adapters.py +0 -0
  35. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/const.py +0 -0
  36. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/detection.py +0 -0
  37. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/dispatch.py +0 -0
  38. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/__init__.py +0 -0
  39. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  40. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/const.py +0 -0
  41. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/control.py +0 -0
  42. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/models.py +0 -0
  43. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/phase_validation.py +0 -0
  44. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/py.typed +0 -0
  45. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/src/span_panel_api/schema_drift.py +0 -0
  46. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/conftest.py +0 -0
  47. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  48. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  49. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  50. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/flat_wire.json +0 -0
  51. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  52. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/panelbench_wire.json +0 -0
  53. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/v2/README.md +0 -0
  54. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/fixtures/v2/status.json +0 -0
  55. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/reference_payloads/README.md +0 -0
  56. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/reference_payloads/__init__.py +0 -0
  57. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/reference_payloads/bootstrap.py +0 -0
  58. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/reference_payloads/schema_one.py +0 -0
  59. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  60. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  61. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  62. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/simulation_fixtures/status.response.txt +0 -0
  63. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_absent_readings_are_not_zero.py +0 -0
  64. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_accumulator.py +0 -0
  65. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_adapters_discovery.py +0 -0
  66. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_adopted_control.py +0 -0
  67. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_adoption.py +0 -0
  68. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_async_mqtt_client.py +0 -0
  69. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_auth_and_homie_helpers.py +0 -0
  70. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_auth_redaction.py +0 -0
  71. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_ca_pinning.py +0 -0
  72. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_catalog_divergence.py +0 -0
  73. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_control_interceptor.py +0 -0
  74. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_exceptions.py +0 -0
  75. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_factory_dispatch.py +0 -0
  76. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_field_metadata.py +0 -0
  77. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_leaf_name_mismatch.py +0 -0
  78. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_live_flat_differential.py +0 -0
  79. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_mqtt_bridge.py +0 -0
  80. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_mqtt_client_connection.py +0 -0
  81. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_mqtt_debounce.py +0 -0
  82. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_packaging.py +0 -0
  83. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_phase_validation_configs.py +0 -0
  84. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_phase_validation_errors.py +0 -0
  85. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_plaintext_warning.py +0 -0
  86. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_protocol_conformance.py +0 -0
  87. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_protocol_models.py +0 -0
  88. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_publish_outcome.py +0 -0
  89. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_redispatch_on_reconnect.py +0 -0
  90. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_reference_tree_values.py +0 -0
  91. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_fetch_transport_split.py +0 -0
  92. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_generation_cross_check.py +0 -0
  93. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_migration_delta.py +0 -0
  94. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_circuits.py +0 -0
  95. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_conformance.py +0 -0
  96. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_extension.py +0 -0
  97. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_pcs.py +0 -0
  98. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_service_entrance.py +0 -0
  99. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_shed_forecast.py +0 -0
  100. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_one_transport.py +0 -0
  101. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_provenance.py +0 -0
  102. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_schema_zero_adapter.py +0 -0
  103. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_shared_http_client.py +0 -0
  104. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_ssl_context.py +0 -0
  105. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/test_v2_status_parser.py +0 -0
  106. {span_panel_api-3.4.1 → span_panel_api-3.6.0}/tests/tls_fixtures.py +0 -0
@@ -7,6 +7,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
7
7
  Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the **last public release**, never against the beta before it. What one
8
8
  beta corrected in an earlier beta does not appear at all: from the point of view of somebody upgrading between released versions, it never happened.
9
9
 
10
+ ## [3.6.0]
11
+
12
+ The snapshot carries every PV inverter a panel commissions, and registration copes with a panel that cannot read its own passphrase.
13
+
14
+ ### Added
15
+
16
+ - **`SpanPanelSnapshot.pv_inverters`** carries every commissioned PV inverter, keyed by its feeding circuit's id, which stays put when firmware r202639 renames a panel's inverters, or by its device id where no circuit feeds it.
17
+ - **`SpanPVSnapshot.serial_number`, `device_id` and `node_id`** give an inverter's serial number when one is published, its id on the wire, and its key in `pv_inverters`.
18
+ - **`SpanEvseSnapshot.effective_charge_current_limit_a`** is the charge-current limit a charger is applying, its user limit when one is published and its ceiling otherwise, since from firmware r202639 a SPAN Drive publishes a user limit only once someone
19
+ sets one.
20
+ - **`SpanPanelPassphraseUnavailableError`**, raised by `register_v2` and `create_span_client` for a panel that cannot read its own passphrase, is a `SpanPanelAPIError` rather than a `SpanPanelAuthError` because the passphrase the user gave may be correct.
21
+
22
+ ### Changed
23
+
24
+ - **`V2AuthResponse.ebus_broker_password` and `hop_passphrase` are `str | None`**, `None` when a panel on firmware r202639 or later cannot read its passphrase yet still issues a valid access token.
25
+ - **`register_v2` raises `SpanPanelServerError` with `status_code` for any 5xx**, including the 503 a panel on firmware r202639 answers until it knows its serial number, where it raised a plain `SpanPanelAPIError`.
26
+ - **`SpanPanelSnapshot.pv` is the inverter on the lowest breaker space when more than one is commissioned**, rather than whichever one the adapter met first.
27
+ - **`SpanPVSnapshot.nameplate_capacity_w` is documented as the array's DC size recorded at installation**, an informational figure and never a ceiling on PV power.
28
+
29
+ ## [3.5.0]
30
+
31
+ A rotation replaces the panel passphrase as well as the broker password, and the library now hands back both, so a caller no longer loses the only copy of the user's new passphrase. A broker that refuses the credentials is now reported as an authentication
32
+ failure rather than a connection failure.
33
+
34
+ **Upgrade note.** `connect()` raises `SpanPanelAuthError`, where it raised `SpanPanelConnectionError`, when the broker refuses the credentials (CONNACK "Bad user name or password" or "Not authorized"). The two are siblings under `SpanPanelError`, so a
35
+ caller that caught `SpanPanelConnectionError` to handle a refused login must catch `SpanPanelAuthError` too. Right after a rotation the same error is also how a broker that has not yet accepted the new password answers, so retry it with backoff for up to
36
+ about a minute before treating it as wrong credentials.
37
+
38
+ Reported and fixed by [@dcj](https://github.com/dcj) in [#179](https://github.com/SpanPanel/span-panel-api/pull/179), from issue [#178](https://github.com/SpanPanel/span-panel-api/issues/178).
39
+
40
+ ### Added
41
+
42
+ - **`rotate_passphrase()` returns both new values** as a `PassphraseRotation` (`ebus_broker_password`, `hop_passphrase`), because a rotation replaces the hop passphrase as well as the broker password and `register_v2` afterwards accepts only the new one.
43
+ Exported with the result type. Access tokens already issued are not revoked. The broker may not accept the new password the moment the call returns, and the old one may keep working briefly: reconnect with the new one and never fall back to the old one.
44
+ If the broker refuses it, retry with backoff for up to about a minute, including `SpanPanelAuthError`, which `connect()` raises for a refused CONNACK and which is what a broker that has not yet accepted the new password produces. If it is still refused,
45
+ rotate again and use the newly returned value. Do not rely on the old password stopping, or on existing sessions being disconnected, at any particular moment.
46
+ - **`SpanPanelInsufficientPrivilegeError`** for a rotation refused with HTTP 403: the token is reduced-privilege (one obtained by proof of proximity), and registering with the passphrase yields a full-privilege token. A subclass of `SpanPanelAuthError`, so
47
+ existing except clauses still catch it.
48
+
49
+ ### Changed
50
+
51
+ - **`regenerate_passphrase()` no longer claims the hop passphrase is unchanged.** It performs the same rotation and still returns only the new broker password as a `str`.
52
+ - **`set_dominant_power_source` refuses with "Panel offers no settable dominant power source control"** where it said "Core node not found in panel topology", since the parent/child schema has no core node and now also refuses a control the panel does not
53
+ declare settable.
54
+ - **Passphrase rotation raises `SpanPanelServerError` with `status_code` for a 5xx**, instead of a plain `SpanPanelAPIError`. After a 500 the outcome is unknown: the passphrase may or may not have changed.
55
+
56
+ ### Fixed
57
+
58
+ - **A broker that refuses the credentials now raises `SpanPanelAuthError` from `connect()`**, instead of `SpanPanelConnectionError`, so a consumer can ask for new credentials rather than retry. This covers the CONNACK refusals "Bad user name or password"
59
+ and "Not authorized"; the error message carries the reason code. Every other refusal still raises `SpanPanelConnectionError`, and the reconnect loop still retries all of them.
60
+
10
61
  ## [3.4.1]
11
62
 
12
63
  A panel that merely advertises itself on the network no longer produces the plaintext-transport warning when discovery probes it, closing the remaining way issue span#264's log line reached an operator who could do nothing about it.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 3.4.1
3
+ Version: 3.6.0
4
4
  Summary: A client library for SPAN Panel API
5
5
  Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
6
6
  Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
@@ -20,9 +20,9 @@ Requires-Dist: httpx<1.0,>=0.28.1
20
20
  Requires-Dist: paho-mqtt<3.0.0,>=2.0.0
21
21
  Requires-Dist: pyyaml>=6.0.0
22
22
  Provides-Extra: schema-0
23
- Requires-Dist: span-panel-api-schema-0>=1.1.0; extra == 'schema-0'
23
+ Requires-Dist: span-panel-api-schema-0>=1.2.0; extra == 'schema-0'
24
24
  Provides-Extra: schema-1
25
- Requires-Dist: span-panel-api-schema-1>=1.1.0; extra == 'schema-1'
25
+ Requires-Dist: span-panel-api-schema-1>=1.2.0; extra == 'schema-1'
26
26
  Description-Content-Type: text/markdown
27
27
 
28
28
  # SPAN Panel API
@@ -476,7 +476,7 @@ Standalone async functions for v2-specific HTTP operations:
476
476
  ```python
477
477
  from span_panel_api import (
478
478
  register_v2, download_ca_cert, get_homie_schema,
479
- 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.6.0"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -58,8 +58,12 @@ dependencies = [
58
58
  # makes every public protocol member mandatory of every adapter wheel — so a 1.0.0
59
59
  # adapter installed against this bootstrap is rejected at discovery rather than
60
60
  # working in a degraded way. The two must move together.
61
- schema-0 = ["span-panel-api-schema-0>=1.1.0"]
62
- schema-1 = ["span-panel-api-schema-1>=1.1.0"]
61
+ # Raised to 1.2.0 for 3.6.0 for a different reason: no protocol member changed,
62
+ # so a 1.1.x adapter is still accepted, but it fills neither `pv_inverters` nor
63
+ # the inverter identity fields 3.6.0 adds. Upgrading through the extra is what
64
+ # brings the adapters that do.
65
+ schema-0 = ["span-panel-api-schema-0>=1.2.0"]
66
+ schema-1 = ["span-panel-api-schema-1>=1.2.0"]
63
67
 
64
68
  [project.urls]
65
69
  Homepage = "https://github.com/SpanPanel/span-panel-api"
@@ -16,6 +16,7 @@ from .auth import (
16
16
  regenerate_passphrase,
17
17
  register_fqdn,
18
18
  register_v2,
19
+ rotate_passphrase,
19
20
  )
20
21
  from .detection import DetectionResult, detect_api_version
21
22
  from .exceptions import (
@@ -26,6 +27,8 @@ from .exceptions import (
26
27
  SpanPanelCAChangedError,
27
28
  SpanPanelConnectionError,
28
29
  SpanPanelError,
30
+ SpanPanelInsufficientPrivilegeError,
31
+ SpanPanelPassphraseUnavailableError,
29
32
  SpanPanelSchemaVersionError,
30
33
  SpanPanelServerError,
31
34
  SpanPanelStaleDataError,
@@ -46,6 +49,7 @@ from .models import (
46
49
  ExtensionSubject,
47
50
  FieldMetadata,
48
51
  HomieSchemaTypes,
52
+ PassphraseRotation,
49
53
  SpanBatterySnapshot,
50
54
  SpanCircuitSnapshot,
51
55
  SpanEvseSnapshot,
@@ -172,6 +176,11 @@ __all__ = [ # noqa: RUF022
172
176
  "register_fqdn",
173
177
  "regenerate_passphrase",
174
178
  "register_v2",
179
+ # Added 2026-10-02 (3.5.0): the rotation reports both new values, because
180
+ # it replaces the hop passphrase as well as the broker password. Additive --
181
+ # regenerate_passphrase keeps its str return.
182
+ "rotate_passphrase",
183
+ "PassphraseRotation",
175
184
  # Transport
176
185
  "MqttClientConfig",
177
186
  "SpanMqttClient",
@@ -199,6 +208,14 @@ __all__ = [ # noqa: RUF022
199
208
  "SpanPanelSchemaVersionError",
200
209
  "SpanPanelConnectionError",
201
210
  "SpanPanelError",
211
+ # Added 2026-10-02 (3.5.0): a 403 from a reduced-privilege token. A subclass
212
+ # of SpanPanelAuthError, so every existing except clause keeps its meaning.
213
+ "SpanPanelInsufficientPrivilegeError",
214
+ # Added 2026-10-06 (3.6.0): registration reached a panel that cannot read its own
215
+ # passphrase. A subclass of SpanPanelAPIError, so existing except clauses
216
+ # keep their meaning; deliberately not a SpanPanelAuthError, because the
217
+ # passphrase the user supplied may be correct.
218
+ "SpanPanelPassphraseUnavailableError",
202
219
  "SpanPanelServerError",
203
220
  "SpanPanelStaleDataError",
204
221
  # Added 2026-08-31 (3.4.0): a bootstrap REST call that failed verification
@@ -18,9 +18,15 @@ import uuid
18
18
 
19
19
  import httpx
20
20
 
21
- from ._http import CA_CERT_PATH, V2_STATUS_PATH, _request
22
- from .exceptions import 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 (
23
+ SpanPanelAPIError,
24
+ SpanPanelAuthError,
25
+ SpanPanelInsufficientPrivilegeError,
26
+ SpanPanelPassphraseUnavailableError,
27
+ SpanPanelServerError,
28
+ )
29
+ from .models import HomieSchemaTypes, PassphraseRotation, V2AuthResponse, V2HomieSchema, V2StatusInfo
24
30
 
25
31
  _LOGGER = logging.getLogger(__name__)
26
32
 
@@ -171,6 +177,11 @@ def _str(val: object) -> str:
171
177
  return str(val) if val is not None else ""
172
178
 
173
179
 
180
+ def _optional_str(val: object) -> str | None:
181
+ """Extract a string from a JSON-decoded value that the panel may send as null or omit."""
182
+ return None if val is None else str(val)
183
+
184
+
174
185
  def _int(val: object) -> int:
175
186
  """Extract an int from a JSON-decoded value."""
176
187
  if isinstance(val, int):
@@ -182,6 +193,24 @@ def _int(val: object) -> int:
182
193
 
183
194
  HTTP_TOO_MANY_REQUESTS = 429
184
195
 
196
+ #: The ``detail`` a registration 422 carries when the panel cannot read its own
197
+ #: passphrase. Compared whole and quoted in the exception message, which is safe
198
+ #: only because it is this fixed string and never anything the panel echoed.
199
+ REGISTRATION_UNAVAILABLE_DETAIL = "Dashboard password is not available"
200
+
201
+
202
+ def _error_detail(response: httpx.Response) -> str | None:
203
+ """The ``detail`` string of an error body, or None when there is no such string."""
204
+ try:
205
+ parsed = response.json()
206
+ except ValueError:
207
+ return None
208
+ if not isinstance(parsed, dict):
209
+ return None
210
+ detail = parsed.get("detail")
211
+ return detail if isinstance(detail, str) else None
212
+
213
+
185
214
  #: Default attempts and base backoff used when the panel rate-limits a request.
186
215
  CA_CERT_MAX_ATTEMPTS = 5
187
216
  CA_CERT_BACKOFF_S = 1.5
@@ -240,10 +269,15 @@ async def register_v2(
240
269
  this call to ``https://``; ``None`` is byte-identical to 3.0.1.
241
270
 
242
271
  Returns:
243
- V2AuthResponse with access token and MQTT broker credentials
272
+ V2AuthResponse with access token and MQTT broker credentials. From firmware
273
+ r202639 the broker password and passphrase are ``None`` when the panel
274
+ cannot read its passphrase; the access token is still valid.
244
275
 
245
276
  Raises:
246
277
  SpanPanelAuthError: Invalid passphrase or auth failure
278
+ SpanPanelPassphraseUnavailableError: The panel cannot read its own passphrase
279
+ SpanPanelServerError: The panel is not ready to register clients (any 5xx,
280
+ including the 503 it answers before its serial number is known); retryable
247
281
  SpanPanelConnectionError: Cannot reach panel
248
282
  SpanPanelTimeoutError: Request timed out
249
283
  SpanPanelAPIError: Unexpected response
@@ -267,6 +301,29 @@ async def register_v2(
267
301
  json=payload,
268
302
  )
269
303
 
304
+ sent = () if passphrase is None else (passphrase,)
305
+
306
+ if reply.status_code >= 500:
307
+ # From r202639 the panel answers 503 until it knows its own serial
308
+ # number, which is a panel still starting rather than a refusal. The
309
+ # same class `get_homie_schema` raises for a booting panel, so one retry
310
+ # clause covers both.
311
+ _log_auth_failure(reply.endpoint, reply.response, sent)
312
+ raise SpanPanelServerError(
313
+ f"Panel not ready: HTTP {reply.status_code} from /api/v2/auth/register",
314
+ status_code=reply.status_code,
315
+ )
316
+
317
+ if reply.status_code == 422 and _error_detail(reply.response) == REGISTRATION_UNAVAILABLE_DETAIL:
318
+ # Checked before the general 422 below, which means "credential not
319
+ # accepted". This one is the panel failing to read its own passphrase,
320
+ # and telling a user theirs is wrong would be false.
321
+ _log_auth_failure(reply.endpoint, reply.response, sent)
322
+ raise SpanPanelPassphraseUnavailableError(
323
+ f"Panel cannot register clients: {REGISTRATION_UNAVAILABLE_DETAIL} (HTTP 422)",
324
+ status_code=reply.status_code,
325
+ )
326
+
270
327
  if reply.status_code in (401, 403, 422):
271
328
  # Status only, matching the shape the branch below already uses. The body
272
329
  # is logged at DEBUG instead: a 422 from the panel's validation layer
@@ -276,39 +333,40 @@ async def register_v2(
276
333
  # passphrase goes with it because this is the one place in the library
277
334
  # that knows what was sent, and the panel is under no obligation to
278
335
  # quote it back under a key that names it.
279
- _log_auth_failure(reply.endpoint, reply.response, () if passphrase is None else (passphrase,))
336
+ _log_auth_failure(reply.endpoint, reply.response, sent)
280
337
  raise SpanPanelAuthError(f"Authentication failed (HTTP {reply.status_code})")
281
338
 
282
339
  if reply.status_code != 200:
283
340
  raise SpanPanelAPIError(f"Unexpected response from /api/v2/auth/register: HTTP {reply.status_code}")
284
341
 
342
+ # The two credentials are not required: from r202639 the panel sends them
343
+ # as null, or may omit them, when it cannot read its passphrase, and the
344
+ # access token beside them is still good. Both read as None either way.
285
345
  data = reply.json_object(
286
346
  "accessToken",
287
347
  "tokenType",
288
348
  "iatMs",
289
349
  "ebusBrokerUsername",
290
- "ebusBrokerPassword",
291
350
  "ebusBrokerHost",
292
351
  "ebusBrokerMqttsPort",
293
352
  "ebusBrokerWsPort",
294
353
  "ebusBrokerWssPort",
295
354
  "hostname",
296
355
  "serialNumber",
297
- "hopPassphrase",
298
356
  )
299
357
  return V2AuthResponse(
300
358
  access_token=_str(data["accessToken"]),
301
359
  token_type=_str(data["tokenType"]),
302
360
  iat_ms=_int(data["iatMs"]),
303
361
  ebus_broker_username=_str(data["ebusBrokerUsername"]),
304
- ebus_broker_password=_str(data["ebusBrokerPassword"]),
362
+ ebus_broker_password=_optional_str(data.get("ebusBrokerPassword")),
305
363
  ebus_broker_host=_str(data["ebusBrokerHost"]),
306
364
  ebus_broker_mqtts_port=_int(data["ebusBrokerMqttsPort"]),
307
365
  ebus_broker_ws_port=_int(data["ebusBrokerWsPort"]),
308
366
  ebus_broker_wss_port=_int(data["ebusBrokerWssPort"]),
309
367
  hostname=_str(data["hostname"]),
310
368
  serial_number=_str(data["serialNumber"]),
311
- hop_passphrase=_str(data["hopPassphrase"]),
369
+ hop_passphrase=_optional_str(data.get("hopPassphrase")),
312
370
  )
313
371
 
314
372
 
@@ -491,6 +549,64 @@ async def get_homie_schema(
491
549
  )
492
550
 
493
551
 
552
+ async def rotate_passphrase(
553
+ host: str,
554
+ token: str,
555
+ timeout: float = 10.0,
556
+ port: int | None = None,
557
+ httpx_client: httpx.AsyncClient | None = None,
558
+ ssl_context: ssl.SSLContext | None = None,
559
+ ) -> PassphraseRotation:
560
+ """Rotate the panel's passphrase, which is currently also its MQTT broker password.
561
+
562
+ One PUT replaces both: the hop passphrase that ``register_v2`` accepts and
563
+ the broker password, which the panel reports as two fields currently
564
+ carrying the same new value. Use ``ebus_broker_password`` for the broker.
565
+ Afterwards ``register_v2`` accepts only the new passphrase. Access tokens
566
+ already issued are not revoked.
567
+
568
+ The broker may not accept the new password the moment this call returns,
569
+ and the old one may keep working briefly. Reconnect with the new password
570
+ and never fall back to the old one. If the broker refuses it, retry with
571
+ backoff for up to about a minute, and include ``SpanPanelAuthError`` in the
572
+ retry: ``connect()`` raises it for a refused CONNACK, which is what a broker
573
+ that has not yet accepted the new password produces. If it is still refused
574
+ after that, rotate again and use the newly returned value. Do not rely on
575
+ the old password stopping, or on existing sessions being disconnected, at
576
+ any particular moment.
577
+
578
+ Args:
579
+ host: IP address or hostname of the SPAN Panel
580
+ token: Valid JWT access token
581
+ timeout: Request timeout in seconds when ``httpx_client`` is None; ignored when injected.
582
+ port: Port of the panel bootstrap API. ``None`` means "unspecified" and takes
583
+ the scheme's default -- 80 without ``ssl_context``, 443 with one.
584
+ httpx_client: Optional shared ``httpx.AsyncClient``; not closed by this function.
585
+ Not used when ``ssl_context`` is supplied -- httpx fixes its trust store at
586
+ construction, so a pinned CA needs a client built for it. See ``_get_client``.
587
+ ssl_context: Trust anchor for the panel's HTTPS certificate. Supplying one moves
588
+ this call to ``https://``; ``None`` is byte-identical to 3.0.1.
589
+
590
+ Returns:
591
+ The new broker password and the new hop passphrase.
592
+
593
+ Raises:
594
+ SpanPanelInsufficientPrivilegeError: HTTP 403; the token is reduced-privilege
595
+ SpanPanelAuthError: HTTP 401 (token invalid or stale) or 412 (no bearer token)
596
+ SpanPanelServerError: HTTP 5xx. After a 500 the outcome is unknown: the
597
+ passphrase may or may not have changed.
598
+ SpanPanelConnectionError: Cannot reach panel
599
+ SpanPanelTimeoutError: Request timed out
600
+ SpanPanelAPIError: Unexpected response
601
+ """
602
+ reply = await _put_passphrase(host, token, timeout, port, httpx_client, ssl_context)
603
+ data = reply.json_object("ebusBrokerPassword", "hopPassphrase")
604
+ return PassphraseRotation(
605
+ ebus_broker_password=_str(data["ebusBrokerPassword"]),
606
+ hop_passphrase=_str(data["hopPassphrase"]),
607
+ )
608
+
609
+
494
610
  async def regenerate_passphrase(
495
611
  host: str,
496
612
  token: str,
@@ -499,11 +615,13 @@ async def regenerate_passphrase(
499
615
  httpx_client: httpx.AsyncClient | None = None,
500
616
  ssl_context: ssl.SSLContext | None = None,
501
617
  ) -> str:
502
- """Rotate the MQTT broker password on the SPAN Panel.
618
+ """Rotate the panel's passphrase and return only the new broker password.
503
619
 
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.
620
+ The same PUT as ``rotate_passphrase``, with the same effect: the hop
621
+ passphrase changes too, ``register_v2`` accepts only the new one, access
622
+ tokens already issued are not revoked, and the same reconnect advice
623
+ applies. Use ``rotate_passphrase`` to receive both values; this function is
624
+ kept for callers written against its ``str`` return.
507
625
 
508
626
  Args:
509
627
  host: IP address or hostname of the SPAN Panel
@@ -521,11 +639,21 @@ async def regenerate_passphrase(
521
639
  New MQTT broker password
522
640
 
523
641
  Raises:
524
- SpanPanelAuthError: Token invalid or expired
525
- SpanPanelConnectionError: Cannot reach panel
526
- SpanPanelTimeoutError: Request timed out
527
- SpanPanelAPIError: Unexpected response
642
+ Exactly what ``rotate_passphrase`` raises.
528
643
  """
644
+ reply = await _put_passphrase(host, token, timeout, port, httpx_client, ssl_context)
645
+ return _str(reply.json_object("ebusBrokerPassword")["ebusBrokerPassword"])
646
+
647
+
648
+ async def _put_passphrase(
649
+ host: str,
650
+ token: str,
651
+ timeout: float,
652
+ port: int | None,
653
+ httpx_client: httpx.AsyncClient | None,
654
+ ssl_context: ssl.SSLContext | None,
655
+ ) -> _Reply:
656
+ """Send the rotation PUT and raise for every status but 200."""
529
657
  reply = await _request(
530
658
  "PUT",
531
659
  host,
@@ -537,13 +665,27 @@ async def regenerate_passphrase(
537
665
  headers=_bearer(token),
538
666
  )
539
667
 
540
- if reply.status_code in (401, 403, 412):
668
+ if reply.status_code == 403:
669
+ raise SpanPanelInsufficientPrivilegeError(
670
+ f"Token lacks the privilege to rotate the passphrase (HTTP {reply.status_code})"
671
+ )
672
+
673
+ if reply.status_code in (401, 412):
541
674
  raise SpanPanelAuthError(f"Authentication failed (HTTP {reply.status_code})")
542
675
 
676
+ if reply.status_code >= 500:
677
+ raise SpanPanelServerError(
678
+ f"Panel could not rotate the passphrase: HTTP {reply.status_code}",
679
+ status_code=reply.status_code,
680
+ )
681
+
543
682
  if reply.status_code != 200:
544
- raise SpanPanelAPIError(f"Failed to regenerate passphrase: HTTP {reply.status_code}")
683
+ raise SpanPanelAPIError(
684
+ f"Failed to regenerate passphrase: HTTP {reply.status_code}",
685
+ status_code=reply.status_code,
686
+ )
545
687
 
546
- return _str(reply.json_object("ebusBrokerPassword")["ebusBrokerPassword"])
688
+ return reply
547
689
 
548
690
 
549
691
  async def register_fqdn(
@@ -9,6 +9,16 @@ class SpanPanelAuthError(SpanPanelError):
9
9
  """Authentication failed."""
10
10
 
11
11
 
12
+ class SpanPanelInsufficientPrivilegeError(SpanPanelAuthError):
13
+ """The token is valid but reduced-privilege, so the panel refused the call (HTTP 403).
14
+
15
+ A token obtained by proof of proximity (the door bypass) carries reduced
16
+ privilege. Registering with the panel's passphrase yields a full-privilege
17
+ token. A subclass of `SpanPanelAuthError`, so an existing except clause
18
+ keeps catching it.
19
+ """
20
+
21
+
12
22
  class SpanPanelConnectionError(SpanPanelError):
13
23
  """Connection to SPAN panel failed."""
14
24
 
@@ -58,6 +68,22 @@ class SpanPanelServerError(SpanPanelAPIError):
58
68
  """
59
69
 
60
70
 
71
+ class SpanPanelPassphraseUnavailableError(SpanPanelAPIError):
72
+ """The panel cannot read its own passphrase, so it cannot issue broker credentials.
73
+
74
+ A fault on the panel, not a rejected credential, which is why this is not a
75
+ `SpanPanelAuthError`: a caller that maps that class to "wrong passphrase"
76
+ would send the user back to retype one that may well be correct.
77
+
78
+ Raised in two places. ``register_v2`` raises it for the 422 a panel answers
79
+ when registration needs the passphrase and the panel cannot read it.
80
+ ``create_span_client`` raises it when registration succeeded but returned no
81
+ broker password, which from firmware r202639 is how a panel in the same
82
+ state answers a registration it can otherwise complete; connecting to the
83
+ broker without a password could only fail later and less clearly.
84
+ """
85
+
86
+
61
87
  class SpanPanelCAChangedError(SpanPanelError):
62
88
  """The panel is presenting a certificate chain from a different CA than the pin.
63
89
 
@@ -15,7 +15,7 @@ from .adapters import resolve_adapter
15
15
  from .auth import get_homie_schema, register_v2
16
16
  from .detection import detect_api_version
17
17
  from .dispatch import select_adapter_key
18
- from .exceptions import SpanPanelAuthError
18
+ from .exceptions import SpanPanelAuthError, SpanPanelPassphraseUnavailableError
19
19
  from .mqtt.client import SpanMqttClient
20
20
  from .mqtt.models import MqttClientConfig
21
21
 
@@ -77,6 +77,10 @@ async def create_span_client(
77
77
  Raises:
78
78
  SpanPanelAuthError: Neither mqtt_config nor passphrase provided,
79
79
  or serial_number could not be determined.
80
+ SpanPanelPassphraseUnavailableError: Registration was attempted and the panel
81
+ cannot read its own passphrase, so it issued no broker password.
82
+ SpanPanelServerError: Registration was attempted and the panel is not ready
83
+ to register clients yet; retryable.
80
84
  SpanPanelConnectionError: Cannot reach panel during detection or registration.
81
85
  SpanPanelTimeoutError: Timeout during detection or registration.
82
86
  SpanPanelSchemaVersionError: The panel reports a data-model-version whose
@@ -90,6 +94,11 @@ async def create_span_client(
90
94
  auth_response = await register_v2(
91
95
  host, _V2_CLIENT_NAME, passphrase, port=port, httpx_client=httpx_client, ssl_context=ssl_context
92
96
  )
97
+ if auth_response.ebus_broker_password is None:
98
+ raise SpanPanelPassphraseUnavailableError(
99
+ "Panel registration returned no MQTT broker password because the panel cannot read "
100
+ "its passphrase; the broker cannot be reached until that is resolved"
101
+ )
93
102
  mqtt_config = MqttClientConfig(
94
103
  broker_host=auth_response.ebus_broker_host,
95
104
  username=auth_response.ebus_broker_username,