span-panel-api 2.5.3__tar.gz → 2.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 (82) hide show
  1. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/CHANGELOG.md +64 -32
  2. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/PKG-INFO +55 -16
  3. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/README.md +54 -15
  4. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/pyproject.toml +1 -1
  5. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/__init__.py +2 -0
  6. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/exceptions.py +9 -0
  7. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/accumulator.py +4 -44
  8. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/client.py +85 -7
  9. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/homie.py +0 -7
  10. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/protocol.py +2 -0
  11. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_accumulator.py +68 -86
  12. span_panel_api-2.6.0/tests/test_exceptions.py +23 -0
  13. span_panel_api-2.6.0/tests/test_mqtt_client_connection.py +431 -0
  14. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_mqtt_homie.py +32 -19
  15. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/uv.lock +4 -22
  16. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.codefactor +0 -0
  17. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.codefactor.yml +0 -0
  18. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.deps-installed +0 -0
  19. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  20. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  21. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.github/dependabot.yml +0 -0
  22. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.github/workflows/ci.yml +0 -0
  23. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.github/workflows/dependabot-auto-approve.yml +0 -0
  24. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.github/workflows/dependabot-auto-merge.yml +0 -0
  25. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.github/workflows/release.yml +0 -0
  26. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.gitignore +0 -0
  27. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.markdownlint-cli2.jsonc +0 -0
  28. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.markdownlint.json +0 -0
  29. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.pre-commit-config.yaml +0 -0
  30. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.prettierrc.json +0 -0
  31. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.vscode/extensions.json +0 -0
  32. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.vscode/tasks.json +0 -0
  33. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/DEVELOPMENT.md +0 -0
  34. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/LICENSE +0 -0
  35. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/SECURITY.md +0 -0
  36. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/conftest.py +0 -0
  37. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/developer_attribute_readme.md +0 -0
  38. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/openapi.json +0 -0
  39. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/pytest.ini +0 -0
  40. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/pytest_output.log +0 -0
  41. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/scripts/__init__.py +0 -0
  42. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/scripts/coverage.py +0 -0
  43. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/scripts/format.sh +0 -0
  44. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/scripts/format_markdown.py +0 -0
  45. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/scripts/test_live_auth.py +0 -0
  46. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/setup-hooks.sh +0 -0
  47. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/_http.py +0 -0
  48. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/auth.py +0 -0
  49. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/const.py +0 -0
  50. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/detection.py +0 -0
  51. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/factory.py +0 -0
  52. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/models.py +0 -0
  53. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/__init__.py +0 -0
  54. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  55. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/connection.py +0 -0
  56. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/const.py +0 -0
  57. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/field_metadata.py +0 -0
  58. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/models.py +0 -0
  59. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/phase_validation.py +0 -0
  60. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/py.typed +0 -0
  61. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/conftest.py +0 -0
  62. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  63. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  64. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  65. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/fixtures/v2/README.md +0 -0
  66. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/fixtures/v2/homie_schema.json +0 -0
  67. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/fixtures/v2/status.json +0 -0
  68. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  69. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  70. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  71. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/simulation_fixtures/status.response.txt +0 -0
  72. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_async_mqtt_client.py +0 -0
  73. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_auth_and_homie_helpers.py +0 -0
  74. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_detection_auth.py +0 -0
  75. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_field_metadata.py +0 -0
  76. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_mqtt_bridge.py +0 -0
  77. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_mqtt_connect_flow.py +0 -0
  78. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_mqtt_debounce.py +0 -0
  79. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_phase_validation_configs.py +0 -0
  80. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_phase_validation_errors.py +0 -0
  81. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_protocol_conformance.py +0 -0
  82. {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_protocol_models.py +0 -0
@@ -4,17 +4,48 @@ 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.5.3] - 04/2026
7
+ ## [2.6.0] - 04/2026
8
+
9
+ ### Added
10
+
11
+ - **`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
12
+ `SpanPanelClientProtocol` so any transport that claims the protocol must implement it.
13
+ - **`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
14
+ semantically distinct states.
15
+
16
+ ### Changed
17
+
18
+ - **`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
19
+ 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.
20
+
21
+ ### Fixed
22
+
23
+ - **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.
24
+ `_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.
25
+
26
+ ### Breaking
27
+
28
+ - Consumers of `get_snapshot()` must now handle `SpanPanelStaleDataError`. Any consumer with a broad `except Exception` (or `except SpanPanelError`) branch already handles this correctly.
29
+
30
+ ## [2.5.4] - 04/2026
31
+
32
+ ### Reverted
33
+
34
+ - **Revert accumulator to 2.5.1 behavior** — the 2.5.2 lifecycle changes (property clearing, unconditional lifecycle transition on `$state=init`, generation counter) caused false energy dip spikes on panel reboots and network interruptions. The 2.5.3
35
+ partial fix (removing the clearing) was insufficient — the unconditional lifecycle disruption on transient `$state=init` events still triggered snapshot pipeline resets that produced 0.0 energy readings. Reverted `accumulator.py` and `homie.py` to their
36
+ stable 2.5.1 state. The existing dirty-node tracking handles reboot transitions correctly without special-case lifecycle management.
37
+
38
+ ## [2.5.3] - 04/2026 (retired)
39
+
40
+ > **Retired:** Partial fix for 2.5.2 — removed property clearing but kept the lifecycle disruption that still caused false dips. Superseded by 2.5.4.
8
41
 
9
42
  ### Fixed
10
43
 
11
- - **Preserve property values on lifecycle reset** — the 2.5.2 property clear caused `_parse_float('')` to return `0.0` for energy counters during panel reboots or network interruptions, triggering false dip-compensation offsets in the integration that
12
- permanently inflated energy sensor values. Removed the property/timestamp/target clearing from `_handle_description()`. Pre-reboot values now serve as safe placeholders until the panel re-publishes fresh data.
13
- - **Snapshot cache invalidated on reboot** — the generation counter still increments on lifecycle resets, forcing consumers to discard cached snapshots and rebuild from current accumulator state.
44
+ - **Preserve property values on lifecycle reset** — removed the property/timestamp/target clearing from `_handle_description()`.
14
45
 
15
46
  ## [2.5.2] - 04/2026 (retired)
16
47
 
17
- > **Retired:** The property-clearing behavior introduced in this release caused false energy dip spikes. Superseded by 2.5.3.
48
+ > **Retired:** Lifecycle changes caused false energy dip spikes. Superseded by 2.5.4.
18
49
 
19
50
  ### Fixed
20
51
 
@@ -344,30 +375,31 @@ Package versions prior to 2.0.0 depend on the SPAN v1 REST API. SPAN will sunset
344
375
 
345
376
  ## Version History Summary
346
377
 
347
- | Version | Date | Transport | Summary |
348
- | ---------- | ------- | ---------- | ----------------------------------------------------------------------------------------- |
349
- | **2.5.3** | 04/2026 | MQTT/Homie | Preserve property values on lifecycle reset; fix false energy dip spikes from 2.5.2 clear |
350
- | **2.5.2** | 04/2026 | MQTT/Homie | _(retired)_ Clear stale property values on panel reboot; caused false energy dip spikes |
351
- | **2.5.1** | 04/2026 | MQTT/Homie | Replace assert with RuntimeError; fix bandit pre-commit hook |
352
- | **2.5.0** | 03/2026 | MQTT/Homie | Homie accumulator layer, $target support, dirty-node snapshot caching |
353
- | **2.4.2** | 03/2026 | MQTT/Homie | SSL context creation moved to executor |
354
- | **2.4.1** | 03/2026 | MQTT/Homie | License metadata, loosened httpx constraint |
355
- | **2.4.0** | 03/2026 | MQTT/Homie | proximityProven, injected HTTP client, executor file I/O, type alias, test cleanup |
356
- | **2.3.2** | 03/2026 | MQTT/Homie | FQDN management endpoints |
357
- | **2.3.1** | 03/2026 | MQTT/Homie | MQTT connection errors wrapped as SpanPanelConnectionError |
358
- | **2.3.0** | 03/2026 | MQTT/Homie | Simulation engine removed |
359
- | **2.2.4** | 03/2026 | MQTT/Homie | Negative zero fix on idle circuits |
360
- | **2.2.3** | 03/2026 | MQTT/Homie | Panel size from Homie schema; `panel_size` always populated on snapshot |
361
- | **2.0.2** | 03/2026 | MQTT/Homie | EVSE (EV charger) snapshot model, Homie parsing, simulation support |
362
- | **2.0.1** | 03/2026 | MQTT/Homie | Full BESS metadata parsing, README documentation |
363
- | **2.0.0** | 02/2026 | MQTT/Homie | Ground-up rewrite: MQTT-only, protocol-based API, real-time push, PV/BESS metadata |
364
- | **1.1.14** | 12/2025 | REST | Keep-Alive and RemoteProtocolError handling |
365
- | **1.1.9** | 9/2025 | REST | Simulation sign corrections |
366
- | **1.1.8** | 2024 | REST | Simulation power sign fix |
367
- | **1.1.6** | 2024 | REST | YAML simulation API, battery simulation |
368
- | **1.1.5** | 2024 | REST | Simulation edge cases |
369
- | **1.1.4** | 2024 | REST | Formatting and linting |
370
- | **1.1.3** | 2024 | REST | Test and lint fixes |
371
- | **1.1.2** | 2024 | REST | Simulation mode added |
372
- | **1.1.1** | 2024 | REST | Dependency updates |
373
- | **1.1.0** | 2024 | REST | Initial release |
378
+ | Version | Date | Transport | Summary |
379
+ | ---------- | ------- | ---------- | ---------------------------------------------------------------------------------- |
380
+ | **2.5.4** | 04/2026 | MQTT/Homie | Revert accumulator to stable 2.5.1 behavior; fixes false energy dip spikes |
381
+ | **2.5.3** | 04/2026 | MQTT/Homie | _(retired)_ Partial fix still caused false dips from lifecycle disruption |
382
+ | **2.5.2** | 04/2026 | MQTT/Homie | _(retired)_ Lifecycle changes caused false energy dip spikes |
383
+ | **2.5.1** | 04/2026 | MQTT/Homie | Replace assert with RuntimeError; fix bandit pre-commit hook |
384
+ | **2.5.0** | 03/2026 | MQTT/Homie | Homie accumulator layer, $target support, dirty-node snapshot caching |
385
+ | **2.4.2** | 03/2026 | MQTT/Homie | SSL context creation moved to executor |
386
+ | **2.4.1** | 03/2026 | MQTT/Homie | License metadata, loosened httpx constraint |
387
+ | **2.4.0** | 03/2026 | MQTT/Homie | proximityProven, injected HTTP client, executor file I/O, type alias, test cleanup |
388
+ | **2.3.2** | 03/2026 | MQTT/Homie | FQDN management endpoints |
389
+ | **2.3.1** | 03/2026 | MQTT/Homie | MQTT connection errors wrapped as SpanPanelConnectionError |
390
+ | **2.3.0** | 03/2026 | MQTT/Homie | Simulation engine removed |
391
+ | **2.2.4** | 03/2026 | MQTT/Homie | Negative zero fix on idle circuits |
392
+ | **2.2.3** | 03/2026 | MQTT/Homie | Panel size from Homie schema; `panel_size` always populated on snapshot |
393
+ | **2.0.2** | 03/2026 | MQTT/Homie | EVSE (EV charger) snapshot model, Homie parsing, simulation support |
394
+ | **2.0.1** | 03/2026 | MQTT/Homie | Full BESS metadata parsing, README documentation |
395
+ | **2.0.0** | 02/2026 | MQTT/Homie | Ground-up rewrite: MQTT-only, protocol-based API, real-time push, PV/BESS metadata |
396
+ | **1.1.14** | 12/2025 | REST | Keep-Alive and RemoteProtocolError handling |
397
+ | **1.1.9** | 9/2025 | REST | Simulation sign corrections |
398
+ | **1.1.8** | 2024 | REST | Simulation power sign fix |
399
+ | **1.1.6** | 2024 | REST | YAML simulation API, battery simulation |
400
+ | **1.1.5** | 2024 | REST | Simulation edge cases |
401
+ | **1.1.4** | 2024 | REST | Formatting and linting |
402
+ | **1.1.3** | 2024 | REST | Test and lint fixes |
403
+ | **1.1.2** | 2024 | REST | Simulation mode added |
404
+ | **1.1.1** | 2024 | REST | Dependency updates |
405
+ | **1.1.0** | 2024 | REST | Initial release |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: span-panel-api
3
- Version: 2.5.3
3
+ Version: 2.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
@@ -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):
@@ -310,17 +333,25 @@ await delete_fqdn("192.168.1.100", token=auth.access_token)
310
333
 
311
334
  All exceptions inherit from `SpanPanelError`:
312
335
 
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 |
336
+ | Exception | Cause |
337
+ | -------------------------- | -------------------------------------------------------------------------------------------------- |
338
+ | `SpanPanelAuthError` | Invalid passphrase, expired token, or missing credentials |
339
+ | `SpanPanelConnectionError` | Cannot reach the panel (network/DNS) during initial connect |
340
+ | `SpanPanelStaleDataError` | `get_snapshot()` called while the broker is disconnected or the Homie device has not reached ready |
341
+ | `SpanPanelTimeoutError` | Request or connection timed out |
342
+ | `SpanPanelValidationError` | Data validation failure |
343
+ | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
344
+ | `SpanPanelServerError` | Panel returned HTTP 500 |
345
+
346
+ `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
347
+ client cannot be used at all.
321
348
 
322
349
  ```python
323
- from span_panel_api import SpanPanelAuthError, SpanPanelConnectionError
350
+ from span_panel_api import (
351
+ SpanPanelAuthError,
352
+ SpanPanelConnectionError,
353
+ SpanPanelStaleDataError,
354
+ )
324
355
 
325
356
  try:
326
357
  client = await create_span_client(host="192.168.1.100", passphrase="wrong")
@@ -328,6 +359,14 @@ except SpanPanelAuthError:
328
359
  print("Invalid passphrase")
329
360
  except SpanPanelConnectionError:
330
361
  print("Cannot reach panel")
362
+
363
+ # Later, during normal operation:
364
+ try:
365
+ snapshot = await client.get_snapshot()
366
+ except SpanPanelStaleDataError as err:
367
+ # Broker dropped or panel declared not-ready — fall back to last-known
368
+ # data, a grace-period value, or mark downstream state unavailable.
369
+ print(f"Snapshot unavailable: {err}")
331
370
  ```
332
371
 
333
372
  ## 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):
@@ -295,17 +318,25 @@ await delete_fqdn("192.168.1.100", token=auth.access_token)
295
318
 
296
319
  All exceptions inherit from `SpanPanelError`:
297
320
 
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 |
321
+ | Exception | Cause |
322
+ | -------------------------- | -------------------------------------------------------------------------------------------------- |
323
+ | `SpanPanelAuthError` | Invalid passphrase, expired token, or missing credentials |
324
+ | `SpanPanelConnectionError` | Cannot reach the panel (network/DNS) during initial connect |
325
+ | `SpanPanelStaleDataError` | `get_snapshot()` called while the broker is disconnected or the Homie device has not reached ready |
326
+ | `SpanPanelTimeoutError` | Request or connection timed out |
327
+ | `SpanPanelValidationError` | Data validation failure |
328
+ | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
329
+ | `SpanPanelServerError` | Panel returned HTTP 500 |
330
+
331
+ `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
332
+ client cannot be used at all.
306
333
 
307
334
  ```python
308
- from span_panel_api import SpanPanelAuthError, SpanPanelConnectionError
335
+ from span_panel_api import (
336
+ SpanPanelAuthError,
337
+ SpanPanelConnectionError,
338
+ SpanPanelStaleDataError,
339
+ )
309
340
 
310
341
  try:
311
342
  client = await create_span_client(host="192.168.1.100", passphrase="wrong")
@@ -313,6 +344,14 @@ except SpanPanelAuthError:
313
344
  print("Invalid passphrase")
314
345
  except SpanPanelConnectionError:
315
346
  print("Cannot reach panel")
347
+
348
+ # Later, during normal operation:
349
+ try:
350
+ snapshot = await client.get_snapshot()
351
+ except SpanPanelStaleDataError as err:
352
+ # Broker dropped or panel declared not-ready — fall back to last-known
353
+ # data, a grace-period value, or mark downstream state unavailable.
354
+ print(f"Snapshot unavailable: {err}")
316
355
  ```
317
356
 
318
357
  ## Capabilities
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "2.5.3"
3
+ version = "2.6.0"
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
  ]
@@ -34,3 +34,12 @@ class SpanPanelAPIError(SpanPanelError):
34
34
 
35
35
  class SpanPanelServerError(SpanPanelAPIError):
36
36
  """Server error (500)."""
37
+
38
+
39
+ class SpanPanelStaleDataError(SpanPanelError):
40
+ """Raised when get_snapshot() is called while the client isn't live.
41
+
42
+ Distinct from SpanPanelConnectionError: this means the client is running
43
+ but data cannot be trusted right now (broker disconnected, or the Homie
44
+ device has declared $state=disconnected/lost).
45
+ """
@@ -56,11 +56,6 @@ class HomiePropertyAccumulator:
56
56
  # Node type mapping from $description
57
57
  self._node_types: dict[str, str] = {}
58
58
 
59
- # Generation counter — incremented whenever a new lifecycle's
60
- # $description is accepted (including initial boot) so consumers
61
- # can invalidate snapshot caches built from prior-lifecycle data.
62
- self._generation: int = 0
63
-
64
59
  # Dirty tracking
65
60
  self._dirty_nodes: set[str] = set()
66
61
 
@@ -84,11 +79,6 @@ class HomiePropertyAccumulator:
84
79
  """Monotonic timestamp of the last READY transition, 0.0 if never ready."""
85
80
  return self._ready_since
86
81
 
87
- @property
88
- def generation(self) -> int:
89
- """Counter incremented on each new lifecycle's ``$description`` to invalidate consumer caches."""
90
- return self._generation
91
-
92
82
  def is_ready(self) -> bool:
93
83
  """True when lifecycle is READY."""
94
84
  return self._lifecycle == HomieLifecycle.READY
@@ -201,17 +191,10 @@ class HomiePropertyAccumulator:
201
191
  self._received_state_ready = False
202
192
  self._received_description = False
203
193
  else:
204
- # init, sleeping, alert, etc. — connected but not ready.
205
- # Always move out of READY/DESCRIPTION_RECEIVED into a
206
- # non-ready connected lifecycle state.
207
- #
208
- # Reset _received_description so that the upcoming $description
209
- # triggers a property clear. This covers fast reboots where
210
- # the broker's LWT ($state=disconnected) may not reach us
211
- # before the panel publishes $state=init.
212
- self._lifecycle = HomieLifecycle.CONNECTED
194
+ # init, sleeping, alert, etc. — connected but not ready
195
+ if self._lifecycle == HomieLifecycle.DISCONNECTED:
196
+ self._lifecycle = HomieLifecycle.CONNECTED
213
197
  self._received_state_ready = False
214
- self._received_description = False
215
198
 
216
199
  _LOGGER.debug("Homie $state: %s → lifecycle=%s", payload, self._lifecycle.value)
217
200
 
@@ -223,25 +206,6 @@ class HomiePropertyAccumulator:
223
206
  _LOGGER.warning("Invalid $description JSON")
224
207
  return
225
208
 
226
- # _handle_state() already reset _received_description to False due to
227
- # a state change that starts a new panel lifecycle, including
228
- # $state=disconnected/lost and other non-ready states such as init.
229
- # Increment the generation counter to invalidate consumer snapshot
230
- # caches, but preserve property values — pre-reboot readings serve
231
- # as safe placeholders until the panel re-publishes. Clearing values
232
- # would emit 0.0 for energy counters via _parse_float(""), triggering
233
- # false dip-compensation offsets in the integration.
234
- #
235
- # On a pure MQTT reconnect (no panel reboot), _received_description
236
- # is still True from the previous session so we skip this block —
237
- # the retained property messages carry the correct (unchanged) values.
238
- if not self._received_description:
239
- self._generation += 1
240
- _LOGGER.debug(
241
- "Panel reboot detected (generation %d); preserving property values as placeholders",
242
- self._generation,
243
- )
244
-
245
209
  self._received_description = True
246
210
  self._node_types.clear()
247
211
 
@@ -256,11 +220,7 @@ class HomiePropertyAccumulator:
256
220
  # Mark all known nodes dirty
257
221
  self._dirty_nodes.update(self._node_types.keys())
258
222
 
259
- _LOGGER.debug(
260
- "Parsed $description with %d nodes (generation %d)",
261
- len(self._node_types),
262
- self._generation,
263
- )
223
+ _LOGGER.debug("Parsed $description with %d nodes", len(self._node_types))
264
224
 
265
225
  # Lifecycle transition
266
226
  if self._received_state_ready:
@@ -9,10 +9,11 @@ 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
13
14
 
14
15
  from ..auth import get_homie_schema
15
- from ..exceptions import SpanPanelConnectionError, SpanPanelServerError
16
+ from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError
16
17
  from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot
17
18
  from ..protocol import PanelCapability
18
19
  from .accumulator import HomiePropertyAccumulator
@@ -52,6 +53,8 @@ class SpanMqttClient:
52
53
  self._homie: HomieDeviceConsumer | None = None
53
54
  self._streaming = False
54
55
  self._snapshot_callbacks: list[Callable[[SpanPanelSnapshot], Awaitable[None]]] = []
56
+ self._connection_callbacks: list[Callable[[bool], None]] = []
57
+ self._live = False
55
58
  self._ready_event: asyncio.Event | None = None
56
59
  self._loop: asyncio.AbstractEventLoop | None = None
57
60
  self._background_tasks: set[asyncio.Task[None]] = set()
@@ -190,6 +193,7 @@ class SpanMqttClient:
190
193
  await self._bridge.disconnect()
191
194
  self._bridge = None
192
195
  self._accumulator = None
196
+ self._live = False
193
197
 
194
198
  async def ping(self) -> bool:
195
199
  """Check if MQTT connection is alive and device is ready."""
@@ -197,12 +201,43 @@ class SpanMqttClient:
197
201
  return False
198
202
  return self._bridge.is_connected() and self._homie.is_ready()
199
203
 
204
+ def register_connection_callback(self, callback: Callable[[bool], None]) -> Callable[[], None]:
205
+ """Subscribe to broker connection state transitions.
206
+
207
+ Callback fires with False on broker disconnect and True on reconnect.
208
+ No synthetic call is made at registration time — callbacks only fire
209
+ on real state edges. To check current connection state on registration,
210
+ await ping().
211
+
212
+ Returns an unregister function that removes the callback from the
213
+ dispatch list. Calling unregister twice is safe.
214
+ """
215
+ self._connection_callbacks.append(callback)
216
+
217
+ def unregister() -> None:
218
+ with contextlib.suppress(ValueError):
219
+ self._connection_callbacks.remove(callback)
220
+
221
+ return unregister
222
+
200
223
  async def get_snapshot(self) -> SpanPanelSnapshot:
201
224
  """Return current snapshot from accumulated MQTT state.
202
225
 
203
- No network call snapshot is built from in-memory property values.
226
+ Raises SpanPanelStaleDataError if the client is not fully live.
227
+ "Live" means: the bridge is connected AND the Homie accumulator
228
+ has reached ready state. Callers can treat SpanPanelStaleDataError
229
+ as the canonical "panel currently unreachable" signal.
230
+
231
+ No network call — snapshot is built from in-memory property values
232
+ when the liveness checks pass.
204
233
  """
205
- return self._require_homie().build_snapshot()
234
+ if self._bridge is None or self._homie is None:
235
+ raise SpanPanelStaleDataError("Client not connected — call connect() first")
236
+ if not self._bridge.is_connected():
237
+ raise SpanPanelStaleDataError("MQTT broker disconnected")
238
+ if not self._homie.is_ready():
239
+ raise SpanPanelStaleDataError("Homie device not ready")
240
+ return self._homie.build_snapshot()
206
241
 
207
242
  # -- CircuitControlProtocol --------------------------------------------
208
243
 
@@ -296,15 +331,42 @@ class SpanMqttClient:
296
331
  self._snapshot_timer = self._loop.call_later(self._snapshot_interval, self._fire_snapshot)
297
332
 
298
333
  def _on_connection_change(self, connected: bool) -> None:
299
- """Handle MQTT connection state change (called from asyncio loop)."""
334
+ """Handle MQTT connection state change (called from asyncio loop).
335
+
336
+ Re-subscribes to the wildcard topic on reconnect (pre-existing
337
+ behavior), then fans out an edge-only notification to registered
338
+ connection callbacks. Duplicate state transitions are suppressed
339
+ so subscribers only see real edges.
340
+
341
+ On disconnect, any pending snapshot-debounce timer is cancelled
342
+ so a stale timer cannot dispatch a post-disconnect snapshot.
343
+ """
344
+ # Re-subscribe runs on every connected=True, including duplicates —
345
+ # paho may re-emit connected events after session restoration, and
346
+ # re-subscribing is broker-benign. Callback fan-out below is
347
+ # edge-only (see the guard after this block).
300
348
  if connected:
301
349
  _LOGGER.debug("MQTT connection established")
302
- # Re-subscribe on reconnect
303
350
  if self._bridge is not None:
304
351
  wildcard = WILDCARD_TOPIC_FMT.format(serial=self._serial_number)
305
352
  self._bridge.subscribe(wildcard, qos=0)
306
353
  else:
307
354
  _LOGGER.debug("MQTT connection lost")
355
+ # Cancel any pending snapshot-debounce timer so it cannot
356
+ # fire post-disconnect with a stale snapshot.
357
+ self._cancel_snapshot_timer()
358
+
359
+ # Edge-only dispatch
360
+ if connected == self._live:
361
+ return
362
+ self._live = connected
363
+
364
+ # Iterate a copy — subscribers may unregister during their callback
365
+ for cb in list(self._connection_callbacks):
366
+ try:
367
+ cb(connected)
368
+ except Exception: # pylint: disable=broad-exception-caught
369
+ _LOGGER.exception("Connection callback raised")
308
370
 
309
371
  async def _wait_for_circuit_names(self, timeout: float) -> None:
310
372
  """Wait for all circuit-like nodes to have a ``name`` property.
@@ -364,8 +426,24 @@ class SpanMqttClient:
364
426
  self._cancel_snapshot_timer()
365
427
 
366
428
  async def _dispatch_snapshot(self) -> None:
367
- """Build snapshot and send to all registered callbacks."""
368
- snapshot = self._require_homie().build_snapshot()
429
+ """Build snapshot and send to all registered callbacks.
430
+
431
+ Guarded by the same liveness predicate as get_snapshot() — if the
432
+ bridge has disconnected or the Homie device is not ready, no
433
+ dispatch occurs. This prevents a pending debounce timer that was
434
+ scheduled just before a disconnect from delivering a stale
435
+ snapshot to subscribers after the fact.
436
+ """
437
+ bridge = self._bridge
438
+ homie = self._homie
439
+ if bridge is None or not bridge.is_connected() or homie is None or not homie.is_ready():
440
+ _LOGGER.debug(
441
+ "Skipping stale snapshot dispatch (bridge_connected=%s, homie_ready=%s)",
442
+ bridge is not None and bridge.is_connected(),
443
+ homie is not None and homie.is_ready(),
444
+ )
445
+ return
446
+ snapshot = homie.build_snapshot()
369
447
  for cb in list(self._snapshot_callbacks):
370
448
  try:
371
449
  await cb(snapshot)
@@ -64,7 +64,6 @@ class HomieDeviceConsumer:
64
64
  self._acc = accumulator
65
65
  self._panel_size = panel_size
66
66
  self._cached_snapshot: SpanPanelSnapshot | None = None
67
- self._cache_generation: int = 0
68
67
 
69
68
  # -- Delegation to accumulator -------------------------------------------
70
69
  # These thin wrappers allow SpanMqttClient (and legacy test code) to
@@ -119,12 +118,6 @@ class HomieDeviceConsumer:
119
118
 
120
119
  Must be called after accumulator is_ready() returns True.
121
120
  """
122
- # Invalidate cache when the accumulator generation advances
123
- # (panel reboot cleared all property values).
124
- if self._acc.generation != self._cache_generation:
125
- self._cached_snapshot = None
126
- self._cache_generation = self._acc.generation
127
-
128
121
  dirty = self._acc.dirty_node_ids()
129
122
 
130
123
  if not dirty and self._cached_snapshot is not None:
@@ -46,6 +46,8 @@ class SpanPanelClientProtocol(Protocol):
46
46
 
47
47
  async def get_snapshot(self) -> SpanPanelSnapshot: ...
48
48
 
49
+ def register_connection_callback(self, callback: Callable[[bool], None]) -> Callable[[], None]: ...
50
+
49
51
 
50
52
  @runtime_checkable
51
53
  class CircuitControlProtocol(Protocol):