span-panel-api 2.5.4__tar.gz → 2.6.1__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 (82) hide show
  1. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/CHANGELOG.md +47 -0
  2. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/PKG-INFO +60 -16
  3. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/README.md +59 -15
  4. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/pyproject.toml +1 -1
  5. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/__init__.py +2 -0
  6. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/auth.py +15 -4
  7. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/exceptions.py +9 -3
  8. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/models.py +3 -3
  9. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/mqtt/async_client.py +24 -0
  10. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/mqtt/client.py +93 -11
  11. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/mqtt/connection.py +90 -100
  12. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/mqtt/homie.py +11 -3
  13. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/protocol.py +5 -3
  14. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/conftest.py +1 -7
  15. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/test_detection_auth.py +29 -1
  16. span_panel_api-2.6.1/tests/test_exceptions.py +23 -0
  17. span_panel_api-2.6.1/tests/test_mqtt_client_connection.py +431 -0
  18. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/test_mqtt_connect_flow.py +37 -5
  19. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/test_mqtt_debounce.py +46 -28
  20. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/test_mqtt_homie.py +14 -1
  21. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/uv.lock +22 -4
  22. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.codefactor +0 -0
  23. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.codefactor.yml +0 -0
  24. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.deps-installed +0 -0
  25. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  26. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  27. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.github/dependabot.yml +0 -0
  28. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.github/workflows/ci.yml +0 -0
  29. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.github/workflows/dependabot-auto-approve.yml +0 -0
  30. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.github/workflows/dependabot-auto-merge.yml +0 -0
  31. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.github/workflows/release.yml +0 -0
  32. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.gitignore +0 -0
  33. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.markdownlint-cli2.jsonc +0 -0
  34. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.markdownlint.json +0 -0
  35. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.pre-commit-config.yaml +0 -0
  36. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.prettierrc.json +0 -0
  37. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.vscode/extensions.json +0 -0
  38. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/.vscode/tasks.json +0 -0
  39. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/DEVELOPMENT.md +0 -0
  40. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/LICENSE +0 -0
  41. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/SECURITY.md +0 -0
  42. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/conftest.py +0 -0
  43. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/developer_attribute_readme.md +0 -0
  44. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/openapi.json +0 -0
  45. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/pytest.ini +0 -0
  46. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/pytest_output.log +0 -0
  47. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/scripts/__init__.py +0 -0
  48. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/scripts/coverage.py +0 -0
  49. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/scripts/format.sh +0 -0
  50. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/scripts/format_markdown.py +0 -0
  51. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/scripts/test_live_auth.py +0 -0
  52. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/setup-hooks.sh +0 -0
  53. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/_http.py +0 -0
  54. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/const.py +0 -0
  55. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/detection.py +0 -0
  56. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/factory.py +0 -0
  57. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/mqtt/__init__.py +0 -0
  58. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/mqtt/accumulator.py +0 -0
  59. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/mqtt/const.py +0 -0
  60. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/mqtt/field_metadata.py +0 -0
  61. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/mqtt/models.py +0 -0
  62. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/phase_validation.py +0 -0
  63. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/src/span_panel_api/py.typed +0 -0
  64. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  65. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  66. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  67. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/fixtures/v2/README.md +0 -0
  68. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/fixtures/v2/homie_schema.json +0 -0
  69. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/fixtures/v2/status.json +0 -0
  70. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/simulation_fixtures/circuits.response.txt +0 -0
  71. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/simulation_fixtures/panel.response.txt +0 -0
  72. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/simulation_fixtures/soe.response.txt +0 -0
  73. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/simulation_fixtures/status.response.txt +0 -0
  74. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/test_accumulator.py +0 -0
  75. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/test_async_mqtt_client.py +0 -0
  76. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/test_auth_and_homie_helpers.py +0 -0
  77. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/test_field_metadata.py +0 -0
  78. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/test_mqtt_bridge.py +0 -0
  79. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/test_phase_validation_configs.py +0 -0
  80. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/test_phase_validation_errors.py +0 -0
  81. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/test_protocol_conformance.py +0 -0
  82. {span_panel_api-2.5.4 → span_panel_api-2.6.1}/tests/test_protocol_models.py +0 -0
@@ -4,6 +4,53 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.6.1] - 04/2026
8
+
9
+ ### Changed
10
+
11
+ - **`get_fqdn()` returns `str | None`** — `None` now distinguishes "no FQDN configured" (HTTP 404 or missing field) from an explicit empty string. Callers that treated `""` as "not registered" must update to check for `None`.
12
+ - **Connection callback errors logged at WARNING** — `SpanMqttClient._on_connection_change` now logs callback exceptions via `_LOGGER.warning(..., exc_info=True)` instead of `_LOGGER.exception(...)`, consistent with `_dispatch_snapshot`.
13
+ - **Reconnect loop catches all exceptions** — `AsyncMqttBridge._reconnect_loop` no longer silently drops on non-`OSError` failures (e.g. `WebsocketConnectionError`, `ssl.SSLError`). All exceptions are logged at WARNING and the loop keeps backing off.
14
+ - **Abnormal MQTT disconnects logged at WARNING** — disconnects where `reason_code.is_failure` is true now log at WARNING; clean disconnects continue to log at DEBUG.
15
+
16
+ ### Fixed
17
+
18
+ - **CA certificate no longer written to disk** — `AsyncMqttBridge.connect()` builds the `ssl.SSLContext` from the fetched PEM via `cadata`, eliminating the temp-file lifecycle (and the small leak window on unexpected process exit) that the prior
19
+ `tls_set(ca_certs=path)` path required.
20
+ - **Deprecated `asyncio.get_event_loop()` removed** — `_wait_for_circuit_names` now uses `time.monotonic()`. The previous code emitted a `DeprecationWarning` on Python 3.12+.
21
+ - **Negative-zero on circuit `instant_power_w`** — explicit guard replaces a cryptic `-raw or 0.0` idiom in `HomieDeviceConsumer._build_circuit`.
22
+ - **DSM grid-exchanging heuristic uses epsilon** — replaces `!= 0.0` float comparison with `abs(x) > 1.0 W`, so the `DSM_OFF_GRID` branch is actually reachable when no BESS is commissioned and lugs readings hover near zero.
23
+ - **`SpanPanelAPIError.__str__` override removed** — the override silently hid exception args beyond the first; default `Exception.__str__` is now used.
24
+ - **Paho lock-layout check at import** — `span_panel_api.mqtt.async_client` verifies on import that the `_PAHO_LOCK_ATTRS` list exactly matches paho's `*_mutex` attributes. Raises `RuntimeError` (not `assert`, so `python -O` does not bypass it) on drift.
25
+
26
+ ### Documentation
27
+
28
+ - **`register_v2()`** — docstring now warns that each call creates a new client entry on the panel; callers should persist and reuse the returned `V2AuthResponse` rather than re-registering on every restart.
29
+ - **Stale simulation transport references removed** from `protocol.py` and `models.py` module docstrings.
30
+
31
+ ## [2.6.0] - 04/2026
32
+
33
+ ### Added
34
+
35
+ - **`SpanMqttClient.register_connection_callback(cb)`** — subscribe to broker connection state transitions. Callback fires with `False` on broker disconnect and `True` on reconnect; returns an idempotent unregister function. Added to
36
+ `SpanPanelClientProtocol` so any transport that claims the protocol must implement it.
37
+ - **`SpanPanelStaleDataError`** exception — raised by `get_snapshot()` when the client is not fully live. Derives from `SpanPanelError` (not from `SpanPanelConnectionError`), because "never connected" and "running but data not currently live" are
38
+ semantically distinct states.
39
+
40
+ ### Changed
41
+
42
+ - **`get_snapshot()` contract** — now raises `SpanPanelStaleDataError` when the bridge is not connected or the Homie device has not reached ready state. Previously, the method silently returned a snapshot built from whatever the in-memory accumulator
43
+ happened to hold, which made offline panels indistinguishable from online ones. This is the primary reason the span integration could not detect panel-offline transitions.
44
+
45
+ ### Fixed
46
+
47
+ - **Stale snapshot dispatch after bridge disconnect** — a pending snapshot-debounce timer scheduled just before a bridge disconnect could fire afterwards, delivering a snapshot built from the still-`ready()` accumulator to subscribers.
48
+ `_on_connection_change(False)` now cancels the pending timer, and `_dispatch_snapshot` is now guarded by the same liveness predicate as `get_snapshot()`, so push consumers never receive a post-disconnect stale snapshot.
49
+
50
+ ### Breaking
51
+
52
+ - Consumers of `get_snapshot()` must now handle `SpanPanelStaleDataError`. Any consumer with a broad `except Exception` (or `except SpanPanelError`) branch already handles this correctly.
53
+
7
54
  ## [2.5.4] - 04/2026
8
55
 
9
56
  ### Reverted
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: span-panel-api
3
- Version: 2.5.4
3
+ Version: 2.6.1
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
@@ -86,12 +86,12 @@ This ensures that the first `get_snapshot()` after connect returns human-readabl
86
86
 
87
87
  The library defines three structural subtyping protocols (PEP 544) that both the MQTT transport and the simulation engine implement:
88
88
 
89
- | Protocol | Purpose |
90
- | -------------------------- | ------------------------------------------------------------------------------------- |
91
- | `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot` |
92
- | `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` |
93
- | `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` |
94
- | `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` |
89
+ | Protocol | Purpose |
90
+ | -------------------------- | ------------------------------------------------------------------------------------------ |
91
+ | `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot`, `register_connection_callback` |
92
+ | `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` |
93
+ | `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` |
94
+ | `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` |
95
95
 
96
96
  Integration code programs against these protocols, not transport-specific classes.
97
97
 
@@ -175,6 +175,29 @@ async def main():
175
175
  asyncio.run(main())
176
176
  ```
177
177
 
178
+ ### Connection State Monitoring
179
+
180
+ Push consumers that need to react to broker disconnect/reconnect events — for example, to mark downstream entities offline within a second of a dropped connection rather than waiting on a fallback poll — can register a connection callback. The callback
181
+ fires `False` on disconnect and `True` on reconnect, edge-only (no synthetic call at registration time):
182
+
183
+ ```python
184
+ def on_connection_change(connected: bool) -> None:
185
+ if connected:
186
+ print("Broker connection restored")
187
+ else:
188
+ print("Broker connection lost")
189
+
190
+ unsubscribe_connection = client.register_connection_callback(on_connection_change)
191
+
192
+ # Later, during teardown:
193
+ unsubscribe_connection()
194
+ ```
195
+
196
+ To check the current connection state on demand (for example, just after registering), call `await client.ping()`.
197
+
198
+ When the client is not fully live (broker disconnected, or Homie device not yet ready), `await client.get_snapshot()` raises `SpanPanelStaleDataError` instead of returning cached data. Treat that exception as the canonical "panel currently unreachable"
199
+ signal — see [Error Handling](#error-handling) below.
200
+
178
201
  ### Pre-Built Config Pattern
179
202
 
180
203
  If you already have MQTT broker credentials (e.g., stored from a previous registration):
@@ -227,9 +250,14 @@ await client.connect()
227
250
 
228
251
  `set_snapshot_interval()` controls how often push-mode snapshot callbacks fire. Lower values mean lower latency; higher values reduce CPU usage on constrained hardware. Dirty-node caching (v2.5.0) further reduces per-scan cost by skipping unchanged nodes.
229
252
 
253
+ Passing `0` (or any non-positive value) disables debounce and dispatches a snapshot for every incoming property message — real-time mode, intended for fast consumers.
254
+
230
255
  ```python
231
256
  # Reduce snapshot frequency to every 2 seconds
232
257
  client.set_snapshot_interval(2.0)
258
+
259
+ # Real-time dispatch — every property update triggers a callback
260
+ client.set_snapshot_interval(0)
233
261
  ```
234
262
 
235
263
  ### Circuit Control
@@ -310,17 +338,25 @@ await delete_fqdn("192.168.1.100", token=auth.access_token)
310
338
 
311
339
  All exceptions inherit from `SpanPanelError`:
312
340
 
313
- | Exception | Cause |
314
- | -------------------------- | --------------------------------------------------------- |
315
- | `SpanPanelAuthError` | Invalid passphrase, expired token, or missing credentials |
316
- | `SpanPanelConnectionError` | Cannot reach the panel (network/DNS) |
317
- | `SpanPanelTimeoutError` | Request or connection timed out |
318
- | `SpanPanelValidationError` | Data validation failure |
319
- | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
320
- | `SpanPanelServerError` | Panel returned HTTP 500 |
341
+ | Exception | Cause |
342
+ | -------------------------- | -------------------------------------------------------------------------------------------------- |
343
+ | `SpanPanelAuthError` | Invalid passphrase, expired token, or missing credentials |
344
+ | `SpanPanelConnectionError` | Cannot reach the panel (network/DNS) during initial connect |
345
+ | `SpanPanelStaleDataError` | `get_snapshot()` called while the broker is disconnected or the Homie device has not reached ready |
346
+ | `SpanPanelTimeoutError` | Request or connection timed out |
347
+ | `SpanPanelValidationError` | Data validation failure |
348
+ | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
349
+ | `SpanPanelServerError` | Panel returned HTTP 500 |
350
+
351
+ `SpanPanelStaleDataError` is distinct from `SpanPanelConnectionError`: the former means the client is running but data cannot be trusted right now (transient disconnect, or panel-declared not-ready); the latter means the initial connect failed and the
352
+ client cannot be used at all.
321
353
 
322
354
  ```python
323
- from span_panel_api import SpanPanelAuthError, SpanPanelConnectionError
355
+ from span_panel_api import (
356
+ SpanPanelAuthError,
357
+ SpanPanelConnectionError,
358
+ SpanPanelStaleDataError,
359
+ )
324
360
 
325
361
  try:
326
362
  client = await create_span_client(host="192.168.1.100", passphrase="wrong")
@@ -328,6 +364,14 @@ except SpanPanelAuthError:
328
364
  print("Invalid passphrase")
329
365
  except SpanPanelConnectionError:
330
366
  print("Cannot reach panel")
367
+
368
+ # Later, during normal operation:
369
+ try:
370
+ snapshot = await client.get_snapshot()
371
+ except SpanPanelStaleDataError as err:
372
+ # Broker dropped or panel declared not-ready — fall back to last-known
373
+ # data, a grace-period value, or mark downstream state unavailable.
374
+ print(f"Snapshot unavailable: {err}")
331
375
  ```
332
376
 
333
377
  ## Capabilities
@@ -71,12 +71,12 @@ This ensures that the first `get_snapshot()` after connect returns human-readabl
71
71
 
72
72
  The library defines three structural subtyping protocols (PEP 544) that both the MQTT transport and the simulation engine implement:
73
73
 
74
- | Protocol | Purpose |
75
- | -------------------------- | ------------------------------------------------------------------------------------- |
76
- | `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot` |
77
- | `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` |
78
- | `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` |
79
- | `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` |
74
+ | Protocol | Purpose |
75
+ | -------------------------- | ------------------------------------------------------------------------------------------ |
76
+ | `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot`, `register_connection_callback` |
77
+ | `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` |
78
+ | `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` |
79
+ | `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` |
80
80
 
81
81
  Integration code programs against these protocols, not transport-specific classes.
82
82
 
@@ -160,6 +160,29 @@ async def main():
160
160
  asyncio.run(main())
161
161
  ```
162
162
 
163
+ ### Connection State Monitoring
164
+
165
+ Push consumers that need to react to broker disconnect/reconnect events — for example, to mark downstream entities offline within a second of a dropped connection rather than waiting on a fallback poll — can register a connection callback. The callback
166
+ fires `False` on disconnect and `True` on reconnect, edge-only (no synthetic call at registration time):
167
+
168
+ ```python
169
+ def on_connection_change(connected: bool) -> None:
170
+ if connected:
171
+ print("Broker connection restored")
172
+ else:
173
+ print("Broker connection lost")
174
+
175
+ unsubscribe_connection = client.register_connection_callback(on_connection_change)
176
+
177
+ # Later, during teardown:
178
+ unsubscribe_connection()
179
+ ```
180
+
181
+ To check the current connection state on demand (for example, just after registering), call `await client.ping()`.
182
+
183
+ When the client is not fully live (broker disconnected, or Homie device not yet ready), `await client.get_snapshot()` raises `SpanPanelStaleDataError` instead of returning cached data. Treat that exception as the canonical "panel currently unreachable"
184
+ signal — see [Error Handling](#error-handling) below.
185
+
163
186
  ### Pre-Built Config Pattern
164
187
 
165
188
  If you already have MQTT broker credentials (e.g., stored from a previous registration):
@@ -212,9 +235,14 @@ await client.connect()
212
235
 
213
236
  `set_snapshot_interval()` controls how often push-mode snapshot callbacks fire. Lower values mean lower latency; higher values reduce CPU usage on constrained hardware. Dirty-node caching (v2.5.0) further reduces per-scan cost by skipping unchanged nodes.
214
237
 
238
+ Passing `0` (or any non-positive value) disables debounce and dispatches a snapshot for every incoming property message — real-time mode, intended for fast consumers.
239
+
215
240
  ```python
216
241
  # Reduce snapshot frequency to every 2 seconds
217
242
  client.set_snapshot_interval(2.0)
243
+
244
+ # Real-time dispatch — every property update triggers a callback
245
+ client.set_snapshot_interval(0)
218
246
  ```
219
247
 
220
248
  ### Circuit Control
@@ -295,17 +323,25 @@ await delete_fqdn("192.168.1.100", token=auth.access_token)
295
323
 
296
324
  All exceptions inherit from `SpanPanelError`:
297
325
 
298
- | Exception | Cause |
299
- | -------------------------- | --------------------------------------------------------- |
300
- | `SpanPanelAuthError` | Invalid passphrase, expired token, or missing credentials |
301
- | `SpanPanelConnectionError` | Cannot reach the panel (network/DNS) |
302
- | `SpanPanelTimeoutError` | Request or connection timed out |
303
- | `SpanPanelValidationError` | Data validation failure |
304
- | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
305
- | `SpanPanelServerError` | Panel returned HTTP 500 |
326
+ | Exception | Cause |
327
+ | -------------------------- | -------------------------------------------------------------------------------------------------- |
328
+ | `SpanPanelAuthError` | Invalid passphrase, expired token, or missing credentials |
329
+ | `SpanPanelConnectionError` | Cannot reach the panel (network/DNS) during initial connect |
330
+ | `SpanPanelStaleDataError` | `get_snapshot()` called while the broker is disconnected or the Homie device has not reached ready |
331
+ | `SpanPanelTimeoutError` | Request or connection timed out |
332
+ | `SpanPanelValidationError` | Data validation failure |
333
+ | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
334
+ | `SpanPanelServerError` | Panel returned HTTP 500 |
335
+
336
+ `SpanPanelStaleDataError` is distinct from `SpanPanelConnectionError`: the former means the client is running but data cannot be trusted right now (transient disconnect, or panel-declared not-ready); the latter means the initial connect failed and the
337
+ client cannot be used at all.
306
338
 
307
339
  ```python
308
- from span_panel_api import SpanPanelAuthError, SpanPanelConnectionError
340
+ from span_panel_api import (
341
+ SpanPanelAuthError,
342
+ SpanPanelConnectionError,
343
+ SpanPanelStaleDataError,
344
+ )
309
345
 
310
346
  try:
311
347
  client = await create_span_client(host="192.168.1.100", passphrase="wrong")
@@ -313,6 +349,14 @@ except SpanPanelAuthError:
313
349
  print("Invalid passphrase")
314
350
  except SpanPanelConnectionError:
315
351
  print("Cannot reach panel")
352
+
353
+ # Later, during normal operation:
354
+ try:
355
+ snapshot = await client.get_snapshot()
356
+ except SpanPanelStaleDataError as err:
357
+ # Broker dropped or panel declared not-ready — fall back to last-known
358
+ # data, a grace-period value, or mark downstream state unavailable.
359
+ print(f"Snapshot unavailable: {err}")
316
360
  ```
317
361
 
318
362
  ## Capabilities
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "2.5.4"
3
+ version = "2.6.1"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -23,6 +23,7 @@ from .exceptions import (
23
23
  SpanPanelConnectionError,
24
24
  SpanPanelError,
25
25
  SpanPanelServerError,
26
+ SpanPanelStaleDataError,
26
27
  SpanPanelTimeoutError,
27
28
  SpanPanelValidationError,
28
29
  )
@@ -109,6 +110,7 @@ __all__ = [ # noqa: RUF022
109
110
  "SpanPanelConnectionError",
110
111
  "SpanPanelError",
111
112
  "SpanPanelServerError",
113
+ "SpanPanelStaleDataError",
112
114
  "SpanPanelTimeoutError",
113
115
  "SpanPanelValidationError",
114
116
  ]
@@ -47,6 +47,12 @@ async def register_v2(
47
47
  If ``passphrase`` is provided, it is sent as ``hopPassphrase``; omitting
48
48
  it enables door-bypass registration.
49
49
 
50
+ .. note::
51
+ Every call creates a new registered client entry on the panel. Callers
52
+ should persist and reuse the returned ``V2AuthResponse`` rather than
53
+ re-registering on every restart — otherwise stale entries will
54
+ accumulate over the panel's lifetime.
55
+
50
56
  Args:
51
57
  host: IP address or hostname of the SPAN Panel
52
58
  name: Client display name base (e.g., "home-assistant"); a UUID suffix is appended
@@ -310,7 +316,7 @@ async def get_fqdn(
310
316
  timeout: float = 10.0,
311
317
  port: int = 80,
312
318
  httpx_client: httpx.AsyncClient | None = None,
313
- ) -> str:
319
+ ) -> str | None:
314
320
  """Retrieve the currently registered FQDN from the SPAN Panel.
315
321
 
316
322
  Args:
@@ -321,7 +327,9 @@ async def get_fqdn(
321
327
  httpx_client: Optional shared ``httpx.AsyncClient``; not closed by this function.
322
328
 
323
329
  Returns:
324
- The registered FQDN, or empty string if none is configured
330
+ The registered FQDN string, or ``None`` when no FQDN is configured
331
+ (HTTP 404 or missing ``ebusTlsFqdn`` field). An empty string is only
332
+ returned when the panel reports an explicit empty FQDN value.
325
333
 
326
334
  Raises:
327
335
  SpanPanelAuthError: Token invalid or expired
@@ -344,13 +352,16 @@ async def get_fqdn(
344
352
  raise SpanPanelAuthError(f"Authentication failed (HTTP {response.status_code})")
345
353
 
346
354
  if response.status_code == 404:
347
- return ""
355
+ return None
348
356
 
349
357
  if response.status_code != 200:
350
358
  raise SpanPanelAPIError(f"Failed to get FQDN: HTTP {response.status_code}")
351
359
 
352
360
  data: dict[str, object] = response.json()
353
- return _str(data.get("ebusTlsFqdn"))
361
+ raw = data.get("ebusTlsFqdn")
362
+ if raw is None:
363
+ return None
364
+ return str(raw)
354
365
 
355
366
 
356
367
  async def delete_fqdn(
@@ -28,9 +28,15 @@ class SpanPanelAPIError(SpanPanelError):
28
28
  super().__init__(message)
29
29
  self.status_code = status_code
30
30
 
31
- def __str__(self) -> str:
32
- return self.args[0] if self.args else ""
33
-
34
31
 
35
32
  class SpanPanelServerError(SpanPanelAPIError):
36
33
  """Server error (500)."""
34
+
35
+
36
+ class SpanPanelStaleDataError(SpanPanelError):
37
+ """Raised when get_snapshot() is called while the client isn't live.
38
+
39
+ Distinct from SpanPanelConnectionError: this means the client is running
40
+ but data cannot be trusted right now (broker disconnected, or the Homie
41
+ device has declared $state=disconnected/lost).
42
+ """
@@ -1,8 +1,8 @@
1
1
  """Transport-agnostic snapshot models for SPAN Panel state.
2
2
 
3
- These dataclasses represent panel state regardless of how it was obtained
4
- (REST polling or MQTT push). Energy and power sign conventions are
5
- normalized at the transport boundary — consumers see a consistent view.
3
+ These dataclasses represent panel state as produced by the MQTT/Homie
4
+ transport. Energy and power sign conventions are normalized at the
5
+ transport boundary — consumers see a consistent view.
6
6
 
7
7
  All snapshots are immutable (frozen) and memory-efficient (slots).
8
8
  """
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
  from types import TracebackType
12
12
 
13
13
  from paho.mqtt.client import Client as MQTTClient
14
+ from paho.mqtt.enums import CallbackAPIVersion
14
15
 
15
16
  _PAHO_LOCK_ATTRS = (
16
17
  "_in_callback_mutex",
@@ -23,6 +24,29 @@ _PAHO_LOCK_ATTRS = (
23
24
  )
24
25
 
25
26
 
27
+ def _verify_paho_lock_attrs() -> None:
28
+ """Verify paho-mqtt's lock layout matches the list we monkey-patch.
29
+
30
+ Runs once at import. Raises ``RuntimeError`` if any expected attribute
31
+ is missing (paho renamed/removed one) or if paho grew a new lock we
32
+ don't yet patch. Running ``python -O`` does not bypass this check.
33
+ """
34
+ probe = MQTTClient(callback_api_version=CallbackAPIVersion.VERSION2)
35
+ expected = set(_PAHO_LOCK_ATTRS)
36
+ found = {name for name in vars(probe) if name.endswith("_mutex")}
37
+ missing = expected - found
38
+ extra = found - expected
39
+ if missing or extra:
40
+ raise RuntimeError(
41
+ "paho-mqtt lock attributes changed — NullLock monkey-patch is out of date. "
42
+ f"missing={sorted(missing)}, extra={sorted(extra)}. "
43
+ "Update _PAHO_LOCK_ATTRS in span_panel_api.mqtt.async_client."
44
+ )
45
+
46
+
47
+ _verify_paho_lock_attrs()
48
+
49
+
26
50
  class NullLock:
27
51
  """No-op lock for single-threaded event loop execution.
28
52
 
@@ -9,10 +9,12 @@ from __future__ import annotations
9
9
 
10
10
  import asyncio
11
11
  from collections.abc import Awaitable, Callable
12
+ import contextlib
12
13
  import logging
14
+ import time
13
15
 
14
16
  from ..auth import get_homie_schema
15
- from ..exceptions import SpanPanelConnectionError, SpanPanelServerError
17
+ from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError
16
18
  from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot
17
19
  from ..protocol import PanelCapability
18
20
  from .accumulator import HomiePropertyAccumulator
@@ -52,6 +54,8 @@ class SpanMqttClient:
52
54
  self._homie: HomieDeviceConsumer | None = None
53
55
  self._streaming = False
54
56
  self._snapshot_callbacks: list[Callable[[SpanPanelSnapshot], Awaitable[None]]] = []
57
+ self._connection_callbacks: list[Callable[[bool], None]] = []
58
+ self._live = False
55
59
  self._ready_event: asyncio.Event | None = None
56
60
  self._loop: asyncio.AbstractEventLoop | None = None
57
61
  self._background_tasks: set[asyncio.Task[None]] = set()
@@ -190,6 +194,7 @@ class SpanMqttClient:
190
194
  await self._bridge.disconnect()
191
195
  self._bridge = None
192
196
  self._accumulator = None
197
+ self._live = False
193
198
 
194
199
  async def ping(self) -> bool:
195
200
  """Check if MQTT connection is alive and device is ready."""
@@ -197,12 +202,43 @@ class SpanMqttClient:
197
202
  return False
198
203
  return self._bridge.is_connected() and self._homie.is_ready()
199
204
 
205
+ def register_connection_callback(self, callback: Callable[[bool], None]) -> Callable[[], None]:
206
+ """Subscribe to broker connection state transitions.
207
+
208
+ Callback fires with False on broker disconnect and True on reconnect.
209
+ No synthetic call is made at registration time — callbacks only fire
210
+ on real state edges. To check current connection state on registration,
211
+ await ping().
212
+
213
+ Returns an unregister function that removes the callback from the
214
+ dispatch list. Calling unregister twice is safe.
215
+ """
216
+ self._connection_callbacks.append(callback)
217
+
218
+ def unregister() -> None:
219
+ with contextlib.suppress(ValueError):
220
+ self._connection_callbacks.remove(callback)
221
+
222
+ return unregister
223
+
200
224
  async def get_snapshot(self) -> SpanPanelSnapshot:
201
225
  """Return current snapshot from accumulated MQTT state.
202
226
 
203
- No network call snapshot is built from in-memory property values.
227
+ Raises SpanPanelStaleDataError if the client is not fully live.
228
+ "Live" means: the bridge is connected AND the Homie accumulator
229
+ has reached ready state. Callers can treat SpanPanelStaleDataError
230
+ as the canonical "panel currently unreachable" signal.
231
+
232
+ No network call — snapshot is built from in-memory property values
233
+ when the liveness checks pass.
204
234
  """
205
- return self._require_homie().build_snapshot()
235
+ if self._bridge is None or self._homie is None:
236
+ raise SpanPanelStaleDataError("Client not connected — call connect() first")
237
+ if not self._bridge.is_connected():
238
+ raise SpanPanelStaleDataError("MQTT broker disconnected")
239
+ if not self._homie.is_ready():
240
+ raise SpanPanelStaleDataError("Homie device not ready")
241
+ return self._homie.build_snapshot()
206
242
 
207
243
  # -- CircuitControlProtocol --------------------------------------------
208
244
 
@@ -289,22 +325,49 @@ class SpanMqttClient:
289
325
  # Dispatch snapshot callbacks if streaming
290
326
  if self._streaming and homie.is_ready() and self._loop is not None:
291
327
  if self._snapshot_interval <= 0:
292
- # No debounce — dispatch immediately (backward compat)
328
+ # Real-time mode — dispatch immediately, no debounce.
293
329
  self._create_dispatch_task()
294
330
  elif self._snapshot_timer is None:
295
331
  # Schedule debounced dispatch
296
332
  self._snapshot_timer = self._loop.call_later(self._snapshot_interval, self._fire_snapshot)
297
333
 
298
334
  def _on_connection_change(self, connected: bool) -> None:
299
- """Handle MQTT connection state change (called from asyncio loop)."""
335
+ """Handle MQTT connection state change (called from asyncio loop).
336
+
337
+ Re-subscribes to the wildcard topic on reconnect (pre-existing
338
+ behavior), then fans out an edge-only notification to registered
339
+ connection callbacks. Duplicate state transitions are suppressed
340
+ so subscribers only see real edges.
341
+
342
+ On disconnect, any pending snapshot-debounce timer is cancelled
343
+ so a stale timer cannot dispatch a post-disconnect snapshot.
344
+ """
345
+ # Re-subscribe runs on every connected=True, including duplicates —
346
+ # paho may re-emit connected events after session restoration, and
347
+ # re-subscribing is broker-benign. Callback fan-out below is
348
+ # edge-only (see the guard after this block).
300
349
  if connected:
301
350
  _LOGGER.debug("MQTT connection established")
302
- # Re-subscribe on reconnect
303
351
  if self._bridge is not None:
304
352
  wildcard = WILDCARD_TOPIC_FMT.format(serial=self._serial_number)
305
353
  self._bridge.subscribe(wildcard, qos=0)
306
354
  else:
307
355
  _LOGGER.debug("MQTT connection lost")
356
+ # Cancel any pending snapshot-debounce timer so it cannot
357
+ # fire post-disconnect with a stale snapshot.
358
+ self._cancel_snapshot_timer()
359
+
360
+ # Edge-only dispatch
361
+ if connected == self._live:
362
+ return
363
+ self._live = connected
364
+
365
+ # Iterate a copy — subscribers may unregister during their callback
366
+ for cb in list(self._connection_callbacks):
367
+ try:
368
+ cb(connected)
369
+ except Exception: # pylint: disable=broad-exception-caught
370
+ _LOGGER.warning("Connection callback raised", exc_info=True)
308
371
 
309
372
  async def _wait_for_circuit_names(self, timeout: float) -> None:
310
373
  """Wait for all circuit-like nodes to have a ``name`` property.
@@ -315,8 +378,8 @@ class SpanMqttClient:
315
378
  timeout elapses (non-fatal — entities will use fallback names).
316
379
  """
317
380
  homie = self._require_homie()
318
- deadline = asyncio.get_event_loop().time() + timeout
319
- while asyncio.get_event_loop().time() < deadline:
381
+ deadline = time.monotonic() + timeout
382
+ while time.monotonic() < deadline:
320
383
  missing = homie.circuit_nodes_missing_names()
321
384
  if not missing:
322
385
  _LOGGER.debug("All circuit names received")
@@ -357,15 +420,34 @@ class SpanMqttClient:
357
420
  """Update the snapshot debounce interval at runtime.
358
421
 
359
422
  Args:
360
- interval: Seconds between snapshot dispatches. 0 = no debounce.
423
+ interval: Seconds between snapshot dispatches. ``0`` (or any
424
+ non-positive value) disables debounce and dispatches a
425
+ snapshot for every incoming property message — real-time
426
+ mode, intended for fast consumers.
361
427
  """
362
428
  self._snapshot_interval = interval
363
429
  # Cancel any pending timer so the new interval takes effect on next message
364
430
  self._cancel_snapshot_timer()
365
431
 
366
432
  async def _dispatch_snapshot(self) -> None:
367
- """Build snapshot and send to all registered callbacks."""
368
- snapshot = self._require_homie().build_snapshot()
433
+ """Build snapshot and send to all registered callbacks.
434
+
435
+ Guarded by the same liveness predicate as get_snapshot() — if the
436
+ bridge has disconnected or the Homie device is not ready, no
437
+ dispatch occurs. This prevents a pending debounce timer that was
438
+ scheduled just before a disconnect from delivering a stale
439
+ snapshot to subscribers after the fact.
440
+ """
441
+ bridge = self._bridge
442
+ homie = self._homie
443
+ if bridge is None or not bridge.is_connected() or homie is None or not homie.is_ready():
444
+ _LOGGER.debug(
445
+ "Skipping stale snapshot dispatch (bridge_connected=%s, homie_ready=%s)",
446
+ bridge is not None and bridge.is_connected(),
447
+ homie is not None and homie.is_ready(),
448
+ )
449
+ return
450
+ snapshot = homie.build_snapshot()
369
451
  for cb in list(self._snapshot_callbacks):
370
452
  try:
371
453
  await cb(snapshot)