span-panel-api 3.5.0__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.5.0 → span_panel_api-3.6.0}/CHANGELOG.md +19 -0
  2. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/PKG-INFO +3 -3
  3. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/pyproject.toml +7 -3
  4. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/__init__.py +6 -0
  5. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/auth.py +65 -7
  6. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/exceptions.py +16 -0
  7. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/factory.py +10 -1
  8. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/models.py +106 -16
  9. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_mqtt_homie.py +9 -0
  10. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_public_api_unchanged.py +5 -0
  11. span_panel_api-3.6.0/tests/test_register_passphrase_unavailable.py +148 -0
  12. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_adapter.py +101 -4
  13. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_charge_limit.py +37 -0
  14. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_connection_health.py +48 -0
  15. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_devices.py +128 -0
  16. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_discovery.py +5 -4
  17. span_panel_api-3.6.0/tests/test_schema_one_firmware.py +52 -0
  18. span_panel_api-3.6.0/tests/test_schema_one_snapshot.py +333 -0
  19. span_panel_api-3.5.0/tests/test_schema_one_snapshot.py +0 -142
  20. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/.gitignore +0 -0
  21. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/LICENSE +0 -0
  22. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/README.md +0 -0
  23. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/_http.py +0 -0
  24. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/_ssl.py +0 -0
  25. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/adapters.py +0 -0
  26. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/const.py +0 -0
  27. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/detection.py +0 -0
  28. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/dispatch.py +0 -0
  29. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/__init__.py +0 -0
  30. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  31. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/client.py +0 -0
  32. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/connection.py +0 -0
  33. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/const.py +0 -0
  34. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/control.py +0 -0
  35. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/mqtt/models.py +0 -0
  36. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/phase_validation.py +0 -0
  37. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/protocol.py +0 -0
  38. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/py.typed +0 -0
  39. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/src/span_panel_api/schema_drift.py +0 -0
  40. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/conftest.py +0 -0
  41. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  42. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  43. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  44. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/flat_wire.json +0 -0
  45. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  46. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/panelbench_wire.json +0 -0
  47. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/v2/README.md +0 -0
  48. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/fixtures/v2/status.json +0 -0
  49. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/reference_payloads/README.md +0 -0
  50. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/reference_payloads/__init__.py +0 -0
  51. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/reference_payloads/bootstrap.py +0 -0
  52. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/reference_payloads/schema_one.py +0 -0
  53. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  54. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  55. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  56. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/simulation_fixtures/status.response.txt +0 -0
  57. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_absent_readings_are_not_zero.py +0 -0
  58. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_accumulator.py +0 -0
  59. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_adapters_discovery.py +0 -0
  60. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_adopted_control.py +0 -0
  61. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_adoption.py +0 -0
  62. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_async_mqtt_client.py +0 -0
  63. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_auth_and_homie_helpers.py +0 -0
  64. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_auth_redaction.py +0 -0
  65. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_ca_pinning.py +0 -0
  66. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_catalog_divergence.py +0 -0
  67. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_control_interceptor.py +0 -0
  68. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_detection_auth.py +0 -0
  69. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_exceptions.py +0 -0
  70. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_factory_dispatch.py +0 -0
  71. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_field_metadata.py +0 -0
  72. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_https_transport.py +0 -0
  73. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_leaf_name_mismatch.py +0 -0
  74. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_live_flat_differential.py +0 -0
  75. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_mqtt_bridge.py +0 -0
  76. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_mqtt_client_connection.py +0 -0
  77. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_mqtt_connect_flow.py +0 -0
  78. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_mqtt_debounce.py +0 -0
  79. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_packaging.py +0 -0
  80. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_phase_validation_configs.py +0 -0
  81. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_phase_validation_errors.py +0 -0
  82. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_plaintext_warning.py +0 -0
  83. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_protocol_conformance.py +0 -0
  84. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_protocol_models.py +0 -0
  85. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_publish_outcome.py +0 -0
  86. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_redispatch_on_reconnect.py +0 -0
  87. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_reference_tree_values.py +0 -0
  88. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_rest_transport_contract.py +0 -0
  89. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_fetch_transport_split.py +0 -0
  90. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_generation_cross_check.py +0 -0
  91. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_migration_delta.py +0 -0
  92. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_circuits.py +0 -0
  93. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_conformance.py +0 -0
  94. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_control_refusal.py +0 -0
  95. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_extension.py +0 -0
  96. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_panel.py +0 -0
  97. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_pcs.py +0 -0
  98. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_service_entrance.py +0 -0
  99. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_shed_forecast.py +0 -0
  100. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_one_transport.py +0 -0
  101. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_provenance.py +0 -0
  102. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_schema_zero_adapter.py +0 -0
  103. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_shared_http_client.py +0 -0
  104. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_ssl_context.py +0 -0
  105. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/test_v2_status_parser.py +0 -0
  106. {span_panel_api-3.5.0 → span_panel_api-3.6.0}/tests/tls_fixtures.py +0 -0
@@ -7,6 +7,25 @@ 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
+
10
29
  ## [3.5.0]
11
30
 
12
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 3.5.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "3.5.0"
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"
@@ -28,6 +28,7 @@ from .exceptions import (
28
28
  SpanPanelConnectionError,
29
29
  SpanPanelError,
30
30
  SpanPanelInsufficientPrivilegeError,
31
+ SpanPanelPassphraseUnavailableError,
31
32
  SpanPanelSchemaVersionError,
32
33
  SpanPanelServerError,
33
34
  SpanPanelStaleDataError,
@@ -210,6 +211,11 @@ __all__ = [ # noqa: RUF022
210
211
  # Added 2026-10-02 (3.5.0): a 403 from a reduced-privilege token. A subclass
211
212
  # of SpanPanelAuthError, so every existing except clause keeps its meaning.
212
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",
213
219
  "SpanPanelServerError",
214
220
  "SpanPanelStaleDataError",
215
221
  # Added 2026-08-31 (3.4.0): a bootstrap REST call that failed verification
@@ -19,7 +19,13 @@ import uuid
19
19
  import httpx
20
20
 
21
21
  from ._http import CA_CERT_PATH, V2_STATUS_PATH, _Reply, _request
22
- from .exceptions import SpanPanelAPIError, SpanPanelAuthError, SpanPanelInsufficientPrivilegeError, SpanPanelServerError
22
+ from .exceptions import (
23
+ SpanPanelAPIError,
24
+ SpanPanelAuthError,
25
+ SpanPanelInsufficientPrivilegeError,
26
+ SpanPanelPassphraseUnavailableError,
27
+ SpanPanelServerError,
28
+ )
23
29
  from .models import HomieSchemaTypes, PassphraseRotation, V2AuthResponse, V2HomieSchema, V2StatusInfo
24
30
 
25
31
  _LOGGER = logging.getLogger(__name__)
@@ -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
 
@@ -68,6 +68,22 @@ class SpanPanelServerError(SpanPanelAPIError):
68
68
  """
69
69
 
70
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
+
71
87
  class SpanPanelCAChangedError(SpanPanelError):
72
88
  """The panel is presenting a certificate chain from a different CA than the pin.
73
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,
@@ -74,11 +74,24 @@ class SpanCircuitSnapshot:
74
74
 
75
75
  @dataclass(frozen=True, slots=True)
76
76
  class SpanPVSnapshot:
77
- """PV inverter metadata — populated only when a PV node is commissioned."""
77
+ """One PV inverter's metadata, populated only when a PV device is commissioned.
78
+
79
+ A panel may commission more than one inverter, and from firmware r202639 each
80
+ is published as its own device. `SpanPanelSnapshot.pv_inverters` carries all
81
+ of them; `SpanPanelSnapshot.pv` carries one, chosen as that field documents.
82
+ """
78
83
 
79
84
  vendor_name: str | None = None # pv/vendor-name
80
85
  model: str | None = None # human designation (v1.0 info/model; flat pv/product-name)
81
- nameplate_capacity_w: float | None = None # pv/nameplate-capacity (W)
86
+ nameplate_capacity_w: float | None = None
87
+ """The PV array's DC size as recorded at installation, in watts. Informational.
88
+
89
+ v1.0 `info/nominal-power`; flat `pv/nameplate-capacity`. A figure entered at
90
+ commissioning, never a measured or enforced limit: the inverter can produce
91
+ more or less than this, so a consumer must not treat it as a ceiling on PV
92
+ power or clamp a reading to it.
93
+ """
94
+
82
95
  feed_circuit_id: str | None = None # pv/feed (normalized circuit ID)
83
96
  relative_position: str | None = None # pv/relative-position (IN_PANEL | UPSTREAM | DOWNSTREAM)
84
97
  software_version: str | None = None
@@ -106,6 +119,31 @@ class SpanPVSnapshot:
106
119
  way to say it.
107
120
  """
108
121
 
122
+ serial_number: str | None = None
123
+ """`info/serial-number`, v1.0 only. Frequently unpublished, and that is normal.
124
+
125
+ An inverter's serial is often not recorded at commissioning, so `None` is
126
+ the ordinary case rather than a fault. Shown on a device card, never used as
127
+ an identity: see `node_id`.
128
+ """
129
+
130
+ device_id: str | None = None
131
+ """The inverter's id on the wire: the v1.0 device id, or the flat node id.
132
+
133
+ For addressing and diagnostics, never for identity. On a panel with more
134
+ than one inverter every PV device id changes at firmware r202639, the one
135
+ published before included.
136
+ """
137
+
138
+ node_id: str | None = None
139
+ """This inverter's key in `SpanPanelSnapshot.pv_inverters`; `None` on the empty snapshot.
140
+
141
+ Named after `SpanEvseSnapshot.node_id`, which plays the same role for
142
+ chargers. It is the feeding circuit's id when a circuit feeds the inverter,
143
+ because that id stays put when the inverter's own device id changes, and
144
+ `device_id` otherwise.
145
+ """
146
+
109
147
 
110
148
  @dataclass(frozen=True, slots=True)
111
149
  class SpanMidSnapshot:
@@ -310,7 +348,13 @@ class SpanEvseSnapshot:
310
348
 
311
349
  `None` means the charger declares no such property — `charge-limit.md` reads
312
350
  that as "no adjustable charge-current ceiling; it charges at a fixed rate" —
313
- or that it has not published a value yet.
351
+ or that it has not published a value yet, or, from firmware r202639, that
352
+ no user has set a limit. That release publishes a value only once a user
353
+ sets one, leaving `charge_current_ceiling_a` as the limit in force; earlier
354
+ releases filled it with the ceiling on their own, and that retained value
355
+ can outlive the upgrade. A value equal to the ceiling therefore means the
356
+ same as `None`. Read `effective_charge_current_limit_a` for the limit the
357
+ charger is actually applying.
314
358
 
315
359
  **Not `advertised_current_a`.** That is the current actually being offered
316
360
  to the vehicle, which the capability defines as the `min()` of this, the
@@ -360,6 +404,20 @@ class SpanEvseSnapshot:
360
404
  cases.
361
405
  """
362
406
 
407
+ @property
408
+ def effective_charge_current_limit_a(self) -> int | None:
409
+ """The charge-current limit in force, in amps: the user's, else the ceiling.
410
+
411
+ `charge_current_limit_a` when one is published, otherwise
412
+ `charge_current_ceiling_a`, which is the limit from r202639 whenever no
413
+ user has set one. A stale retained limit equal to the ceiling resolves
414
+ to the same number either way, so no special case is needed for it.
415
+ `None` only when neither half is published.
416
+ """
417
+ if self.charge_current_limit_a is not None:
418
+ return self.charge_current_limit_a
419
+ return self.charge_current_ceiling_a
420
+
363
421
 
364
422
  @dataclass(frozen=True, slots=True)
365
423
  class SpanBatterySnapshot:
@@ -379,23 +437,24 @@ class SpanBatterySnapshot:
379
437
  nameplate_capacity_kwh: float | None = None # bess/nameplate-capacity (kWh)
380
438
  connected: bool | None = None # bess/connected
381
439
 
382
- # The BESS's own `meter/active-power`, v1.0 only. **Charge-positive**, which
383
- # is a sign flip away from the wire: the enclosure meters the BESS the way it
384
- # meters a circuit, so a charging battery reads negative there and positive
385
- # here, exactly as `SpanCircuitSnapshot.instant_power_w` reports a load's
386
- # consumption positive. The snapshot's rule across every power field is that
440
+ # The BESS's own `meter/active-power`, v1.0 only. **Discharge-positive**:
387
441
  # positive means power flowing *out of* the battery, which is discharging.
388
442
  # That is the frame the eBus specification asks of a device's own meter, and
389
- # it is deliberately NOT the into-the-device rule the circuit fields follow:
390
- # the wire input is in the opposite frame, so one negation lands here rather
391
- # than there. Measured against a producer in self-consumption with the grid
392
- # at zero, where the direction cannot be argued.
443
+ # it is deliberately NOT the into-the-device rule
444
+ # `SpanCircuitSnapshot.instant_power_w` follows. Measured against a producer
445
+ # in self-consumption with the grid at zero, where the direction cannot be
446
+ # argued.
447
+ #
448
+ # The wire frame changed under this field at firmware r202639: earlier
449
+ # releases publish the BESS meter charge-positive and the adapter negates it;
450
+ # r202639 and later publish it discharge-positive and it passes through. The
451
+ # field's own convention is the same on both.
393
452
  #
394
453
  # Distinct from `SpanPanelSnapshot.power_flow_battery`, which is the
395
454
  # enclosure's own arbitrated flow figure, passed through untouched and
396
455
  # charge-positive. The two describe the same physical power in opposite
397
456
  # frames, so a consumer rendering both must negate one of them; this one is
398
- # already negated.
457
+ # already in the discharge-positive frame.
399
458
  power_w: float | None = None # v2: bess meter/active-power (W), discharge-positive
400
459
 
401
460
  # `status/communication-state`, v1.0 only: the BESS publisher's report of its
@@ -513,20 +572,27 @@ class DiscoveredMetadata(FieldMetadata):
513
572
 
514
573
  @dataclass(frozen=True, slots=True)
515
574
  class V2AuthResponse:
516
- """Response from POST /api/v2/auth/register."""
575
+ """Response from POST /api/v2/auth/register.
576
+
577
+ ``ebus_broker_password`` and ``hop_passphrase`` are ``None`` when the panel
578
+ could not read its passphrase. From firmware r202639 that no longer fails
579
+ the registration: the access token is still issued and REST calls work, but
580
+ there is no broker password to connect with. Earlier firmware always
581
+ supplied both.
582
+ """
517
583
 
518
584
  access_token: str
519
585
  token_type: str
520
586
  iat_ms: int
521
587
  ebus_broker_username: str
522
- ebus_broker_password: str # Use this for MQTT, NOT hop_passphrase
588
+ ebus_broker_password: str | None # Use this for MQTT, NOT hop_passphrase
523
589
  ebus_broker_host: str
524
590
  ebus_broker_mqtts_port: int
525
591
  ebus_broker_ws_port: int
526
592
  ebus_broker_wss_port: int
527
593
  hostname: str
528
594
  serial_number: str
529
- hop_passphrase: str # For REST auth; currently the same value as ebus_broker_password
595
+ hop_passphrase: str | None # For REST auth; currently the same value as ebus_broker_password
530
596
 
531
597
 
532
598
  @dataclass(frozen=True, slots=True)
@@ -893,6 +959,10 @@ class ExtensionSubject:
893
959
  The EVSE's `node_id` and the circuit's `circuit_id` -- the same keys
894
960
  `snapshot.evse` and `snapshot.circuits` use, so a consumer holding the
895
961
  snapshot resolves the subject with a lookup it already performs.
962
+
963
+ `pv` is a singleton while the panel publishes one inverter. With more than
964
+ one, each inverter's subject carries its `SpanPVSnapshot.node_id`, the key
965
+ `snapshot.pv_inverters` uses.
896
966
  """
897
967
 
898
968
 
@@ -1112,6 +1182,26 @@ class SpanPanelSnapshot:
1112
1182
  circuits: dict[str, SpanCircuitSnapshot] = field(default_factory=dict)
1113
1183
  battery: SpanBatterySnapshot = field(default_factory=SpanBatterySnapshot)
1114
1184
  pv: SpanPVSnapshot = field(default_factory=SpanPVSnapshot)
1185
+ """One PV inverter, or the empty snapshot when none is commissioned.
1186
+
1187
+ With a single inverter it is that inverter. With several it is the one whose
1188
+ feeding circuit occupies the lowest breaker space; an inverter with no
1189
+ feeding circuit ranks after every circuit-fed one, and remaining ties go to
1190
+ the lowest device id. Kept for consumers written before `pv_inverters`,
1191
+ which carries every inverter, this one included.
1192
+ """
1193
+ pv_inverters: dict[str, SpanPVSnapshot] = field(default_factory=dict)
1194
+ """Every commissioned PV inverter, keyed by `SpanPVSnapshot.node_id`.
1195
+
1196
+ The key is the feeding circuit's id where a circuit feeds the inverter, and
1197
+ the inverter's own device id otherwise. Preferring the circuit keeps the key
1198
+ still across firmware r202639, which changes every PV device id on a panel
1199
+ with more than one inverter but leaves circuit ids alone.
1200
+
1201
+ Empty when no inverter is commissioned, and from an adapter that predates
1202
+ the field; a consumer falls back to `pv` in that case. A defaulted snapshot
1203
+ field for the reason `adopted_devices` gives.
1204
+ """
1115
1205
  evse: dict[str, SpanEvseSnapshot] = field(default_factory=dict) # keyed by serial (see SpanEvseSnapshot.node_id)
1116
1206
  mid: SpanMidSnapshot | None = None
1117
1207
  """The islanding authority, when the panel publishes one. v1.0 only.
@@ -667,11 +667,18 @@ class TestHomiePVMetadata:
667
667
  assert snapshot.pv.nameplate_capacity_w == 3960.0
668
668
  assert snapshot.pv.feed_circuit_id == "aabbccdd112233445566778899001122"
669
669
  assert snapshot.pv.relative_position == "IN_PANEL"
670
+ assert snapshot.pv.device_id == "pv-0"
671
+ # Keyed by the feeding circuit, as schema_1 keys each inverter, so the
672
+ # one inverter flat publishes is also the whole of `pv_inverters`.
673
+ assert snapshot.pv.node_id == "aabbccdd112233445566778899001122"
674
+ assert snapshot.pv_inverters == {"aabbccdd112233445566778899001122": snapshot.pv}
670
675
 
671
676
  def test_no_pv_node(self):
672
677
  """Without PV node, pv snapshot has None values."""
673
678
  acc, consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}})
674
679
  snapshot = consumer.build_snapshot()
680
+ assert snapshot.pv_inverters == {}
681
+ assert snapshot.pv.node_id is None
675
682
  assert snapshot.pv.vendor_name is None
676
683
  assert snapshot.pv.model is None
677
684
  assert snapshot.pv.nameplate_capacity_w is None
@@ -694,6 +701,8 @@ class TestHomiePVMetadata:
694
701
  assert snapshot.pv.nameplate_capacity_w is None
695
702
  assert snapshot.pv.feed_circuit_id is None
696
703
  assert snapshot.pv.relative_position is None
704
+ # No feeding circuit, so the node id is the only key there is.
705
+ assert snapshot.pv_inverters == {"pv-0": snapshot.pv}
697
706
 
698
707
 
699
708
  # ---------------------------------------------------------------------------
@@ -161,6 +161,11 @@ EXPECTED_PUBLIC_API = {
161
161
  # Added 2026-10-02 (3.5.0): a 403 from a reduced-privilege token. A subclass
162
162
  # of SpanPanelAuthError, so every existing except clause keeps its meaning.
163
163
  "SpanPanelInsufficientPrivilegeError",
164
+ # Added 2026-10-06 (3.6.0): registration reached a panel that cannot read its own
165
+ # passphrase. Additive, and a SpanPanelAPIError subclass rather than a
166
+ # SpanPanelAuthError, so no existing except clause starts telling a user
167
+ # their passphrase is wrong.
168
+ "SpanPanelPassphraseUnavailableError",
164
169
  "SpanPanelServerError",
165
170
  "SpanPanelStaleDataError",
166
171
  # Added 2026-08-31 (3.4.0): a bootstrap REST call that failed *verification*