span-panel-api 2.5.4__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.4 → span_panel_api-2.6.0}/CHANGELOG.md +23 -0
  2. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/PKG-INFO +55 -16
  3. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/README.md +54 -15
  4. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/pyproject.toml +1 -1
  5. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/__init__.py +2 -0
  6. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/exceptions.py +9 -0
  7. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/client.py +85 -7
  8. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/protocol.py +2 -0
  9. span_panel_api-2.6.0/tests/test_exceptions.py +23 -0
  10. span_panel_api-2.6.0/tests/test_mqtt_client_connection.py +431 -0
  11. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_mqtt_homie.py +14 -1
  12. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/uv.lock +4 -4
  13. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.codefactor +0 -0
  14. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.codefactor.yml +0 -0
  15. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.deps-installed +0 -0
  16. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  17. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  18. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.github/dependabot.yml +0 -0
  19. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.github/workflows/ci.yml +0 -0
  20. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.github/workflows/dependabot-auto-approve.yml +0 -0
  21. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.github/workflows/dependabot-auto-merge.yml +0 -0
  22. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.github/workflows/release.yml +0 -0
  23. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.gitignore +0 -0
  24. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.markdownlint-cli2.jsonc +0 -0
  25. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.markdownlint.json +0 -0
  26. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.pre-commit-config.yaml +0 -0
  27. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.prettierrc.json +0 -0
  28. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.vscode/extensions.json +0 -0
  29. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.vscode/tasks.json +0 -0
  30. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/DEVELOPMENT.md +0 -0
  31. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/LICENSE +0 -0
  32. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/SECURITY.md +0 -0
  33. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/conftest.py +0 -0
  34. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/developer_attribute_readme.md +0 -0
  35. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/openapi.json +0 -0
  36. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/pytest.ini +0 -0
  37. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/pytest_output.log +0 -0
  38. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/scripts/__init__.py +0 -0
  39. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/scripts/coverage.py +0 -0
  40. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/scripts/format.sh +0 -0
  41. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/scripts/format_markdown.py +0 -0
  42. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/scripts/test_live_auth.py +0 -0
  43. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/setup-hooks.sh +0 -0
  44. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/_http.py +0 -0
  45. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/auth.py +0 -0
  46. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/const.py +0 -0
  47. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/detection.py +0 -0
  48. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/factory.py +0 -0
  49. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/models.py +0 -0
  50. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/__init__.py +0 -0
  51. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/accumulator.py +0 -0
  52. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  53. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/connection.py +0 -0
  54. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/const.py +0 -0
  55. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/field_metadata.py +0 -0
  56. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/homie.py +0 -0
  57. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/models.py +0 -0
  58. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/phase_validation.py +0 -0
  59. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/py.typed +0 -0
  60. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/conftest.py +0 -0
  61. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  62. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  63. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  64. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/fixtures/v2/README.md +0 -0
  65. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/fixtures/v2/homie_schema.json +0 -0
  66. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/fixtures/v2/status.json +0 -0
  67. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  68. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  69. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  70. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/simulation_fixtures/status.response.txt +0 -0
  71. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_accumulator.py +0 -0
  72. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_async_mqtt_client.py +0 -0
  73. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_auth_and_homie_helpers.py +0 -0
  74. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_detection_auth.py +0 -0
  75. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_field_metadata.py +0 -0
  76. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_mqtt_bridge.py +0 -0
  77. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_mqtt_connect_flow.py +0 -0
  78. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_mqtt_debounce.py +0 -0
  79. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_phase_validation_configs.py +0 -0
  80. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_phase_validation_errors.py +0 -0
  81. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_protocol_conformance.py +0 -0
  82. {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_protocol_models.py +0 -0
@@ -4,6 +4,29 @@ 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.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
+
7
30
  ## [2.5.4] - 04/2026
8
31
 
9
32
  ### 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.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.4"
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
+ """
@@ -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)
@@ -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):
@@ -0,0 +1,23 @@
1
+ """Tests for the span_panel_api exception hierarchy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from span_panel_api.exceptions import SpanPanelError
6
+
7
+
8
+ def test_stale_data_error_derives_from_span_panel_error() -> None:
9
+ from span_panel_api.exceptions import SpanPanelStaleDataError
10
+
11
+ err = SpanPanelStaleDataError("example")
12
+ assert isinstance(err, SpanPanelError)
13
+ assert str(err) == "example"
14
+
15
+
16
+ def test_stale_data_error_is_distinct_from_connection_error() -> None:
17
+ from span_panel_api.exceptions import (
18
+ SpanPanelConnectionError,
19
+ SpanPanelStaleDataError,
20
+ )
21
+
22
+ err = SpanPanelStaleDataError("example")
23
+ assert not isinstance(err, SpanPanelConnectionError)
@@ -0,0 +1,431 @@
1
+ """Tests for SpanMqttClient connection callbacks and get_snapshot() liveness guards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ import pytest
8
+
9
+ from span_panel_api.exceptions import SpanPanelError, SpanPanelStaleDataError
10
+ from span_panel_api.models import SpanPanelSnapshot
11
+ from span_panel_api.mqtt.client import SpanMqttClient
12
+ from span_panel_api.mqtt.connection import AsyncMqttBridge
13
+ from span_panel_api.mqtt.const import WILDCARD_TOPIC_FMT
14
+ from span_panel_api.mqtt.homie import HomieDeviceConsumer
15
+ from span_panel_api.mqtt.models import MqttClientConfig
16
+
17
+
18
+ def _make_client() -> SpanMqttClient:
19
+ """Build a SpanMqttClient without I/O for unit testing."""
20
+ config = MqttClientConfig(
21
+ broker_host="127.0.0.1",
22
+ username="test",
23
+ password="test",
24
+ )
25
+ return SpanMqttClient("127.0.0.1", "test-serial", config)
26
+
27
+
28
+ class _FakeBridge(AsyncMqttBridge):
29
+ """Minimal bridge stub for get_snapshot() and fan-out tests.
30
+
31
+ Bypasses AsyncMqttBridge.__init__ (which does TLS/CA/network setup) —
32
+ only is_connected() and subscribe() are invoked on this stub.
33
+ """
34
+
35
+ def __init__(self, connected: bool = True) -> None:
36
+ # Intentionally do not call super().__init__ — avoids I/O setup.
37
+ self._connected = connected
38
+ self.subscribed_topics: list[tuple[str, int]] = []
39
+
40
+ def is_connected(self) -> bool:
41
+ return self._connected
42
+
43
+ def subscribe(self, topic: str, qos: int = 0) -> None:
44
+ self.subscribed_topics.append((topic, qos))
45
+
46
+
47
+ class _FakeHomie(HomieDeviceConsumer):
48
+ """Minimal Homie stub for get_snapshot() tests.
49
+
50
+ Bypasses HomieDeviceConsumer.__init__ — only is_ready() and
51
+ build_snapshot() are invoked on this stub.
52
+ """
53
+
54
+ def __init__(self, ready: bool = True, snapshot: SpanPanelSnapshot | None = None) -> None:
55
+ # Intentionally do not call super().__init__ — avoids accumulator setup.
56
+ self._ready_flag = ready
57
+ self._snapshot = snapshot
58
+
59
+ def is_ready(self) -> bool:
60
+ return self._ready_flag
61
+
62
+ def build_snapshot(self) -> SpanPanelSnapshot:
63
+ if self._snapshot is None:
64
+ raise RuntimeError("_FakeHomie: no snapshot configured")
65
+ return self._snapshot
66
+
67
+
68
+ class TestRegisterConnectionCallback:
69
+ """Callback subscription API — structural only (fan-out is tested in Task 4)."""
70
+
71
+ def test_register_returns_unregister_function(self) -> None:
72
+ client = _make_client()
73
+ unregister = client.register_connection_callback(lambda _c: None)
74
+ assert callable(unregister)
75
+
76
+ def test_register_appends_to_callback_list(self) -> None:
77
+ client = _make_client()
78
+ cb = lambda _c: None # noqa: E731
79
+ client.register_connection_callback(cb)
80
+ assert cb in client._connection_callbacks
81
+
82
+ def test_unregister_removes_from_callback_list(self) -> None:
83
+ client = _make_client()
84
+ cb = lambda _c: None # noqa: E731
85
+ unregister = client.register_connection_callback(cb)
86
+ unregister()
87
+ assert cb not in client._connection_callbacks
88
+
89
+ def test_double_unregister_is_noop(self) -> None:
90
+ client = _make_client()
91
+ unregister = client.register_connection_callback(lambda _c: None)
92
+ unregister()
93
+ unregister() # must not raise
94
+
95
+
96
+ class TestConnectionEventDispatch:
97
+ """Edge-only fan-out in _on_connection_change."""
98
+
99
+ def test_multiple_callbacks_all_fire(self) -> None:
100
+ client = _make_client()
101
+ client._bridge = _FakeBridge(connected=True)
102
+ calls_a: list[bool] = []
103
+ calls_b: list[bool] = []
104
+ client.register_connection_callback(calls_a.append)
105
+ client.register_connection_callback(calls_b.append)
106
+
107
+ client._on_connection_change(True)
108
+
109
+ assert calls_a == [True]
110
+ assert calls_b == [True]
111
+
112
+ def test_unregister_prevents_future_calls(self) -> None:
113
+ client = _make_client()
114
+ client._bridge = _FakeBridge(connected=True)
115
+ calls: list[bool] = []
116
+ unregister = client.register_connection_callback(calls.append)
117
+ unregister()
118
+
119
+ client._on_connection_change(True)
120
+
121
+ assert calls == []
122
+
123
+ def test_initial_false_to_true_fires_online(self) -> None:
124
+ client = _make_client()
125
+ client._bridge = _FakeBridge(connected=True)
126
+ calls: list[bool] = []
127
+ client.register_connection_callback(calls.append)
128
+
129
+ client._on_connection_change(True)
130
+
131
+ assert calls == [True]
132
+ assert client._live is True
133
+
134
+ def test_true_to_false_fires_offline(self) -> None:
135
+ client = _make_client()
136
+ client._bridge = _FakeBridge(connected=True)
137
+ client._live = True
138
+ calls: list[bool] = []
139
+ client.register_connection_callback(calls.append)
140
+
141
+ client._on_connection_change(False)
142
+
143
+ assert calls == [False]
144
+ assert client._live is False
145
+
146
+ def test_duplicate_true_suppressed(self) -> None:
147
+ client = _make_client()
148
+ client._bridge = _FakeBridge(connected=True)
149
+ client._live = True
150
+ calls: list[bool] = []
151
+ client.register_connection_callback(calls.append)
152
+
153
+ client._on_connection_change(True)
154
+
155
+ assert calls == []
156
+
157
+ def test_duplicate_false_suppressed(self) -> None:
158
+ client = _make_client()
159
+ client._bridge = _FakeBridge(connected=False)
160
+ client._live = False
161
+ calls: list[bool] = []
162
+ client.register_connection_callback(calls.append)
163
+
164
+ client._on_connection_change(False)
165
+
166
+ assert calls == []
167
+
168
+ def test_callback_exception_does_not_break_fanout(self, caplog: pytest.LogCaptureFixture) -> None:
169
+ client = _make_client()
170
+ client._bridge = _FakeBridge(connected=True)
171
+
172
+ def bad(_connected: bool) -> None:
173
+ raise RuntimeError("intentional")
174
+
175
+ good_calls: list[bool] = []
176
+ client.register_connection_callback(bad)
177
+ client.register_connection_callback(good_calls.append)
178
+
179
+ with caplog.at_level(logging.ERROR):
180
+ client._on_connection_change(True)
181
+
182
+ assert good_calls == [True]
183
+ assert any("Connection callback raised" in r.message for r in caplog.records)
184
+
185
+ def test_unregister_during_fanout_safe(self) -> None:
186
+ client = _make_client()
187
+ client._bridge = _FakeBridge(connected=True)
188
+
189
+ order: list[str] = []
190
+ unregister_holder: dict[str, object] = {}
191
+
192
+ def first(_connected: bool) -> None:
193
+ order.append("first")
194
+ unregister_fn = unregister_holder["unregister"]
195
+ assert callable(unregister_fn)
196
+ unregister_fn()
197
+
198
+ def second(_connected: bool) -> None:
199
+ order.append("second")
200
+
201
+ unregister_holder["unregister"] = client.register_connection_callback(first)
202
+ client.register_connection_callback(second)
203
+
204
+ client._on_connection_change(True)
205
+
206
+ assert order == ["first", "second"]
207
+ assert first not in client._connection_callbacks
208
+
209
+ def test_reconnect_triggers_resubscribe_and_callback(self) -> None:
210
+ client = _make_client()
211
+ bridge = _FakeBridge(connected=True)
212
+ client._bridge = bridge
213
+ client._live = False # was offline
214
+ calls: list[bool] = []
215
+ client.register_connection_callback(calls.append)
216
+
217
+ client._on_connection_change(True)
218
+
219
+ expected_topic = WILDCARD_TOPIC_FMT.format(serial="test-serial")
220
+ assert len(bridge.subscribed_topics) == 1
221
+ assert bridge.subscribed_topics[0] == (expected_topic, 0)
222
+ assert calls == [True]
223
+
224
+ def test_resubscribe_fires_even_on_duplicate_true(self) -> None:
225
+ """Duplicate connected=True still triggers re-subscribe (intentional).
226
+
227
+ Paho may re-emit connected events; re-subscribing is broker-benign
228
+ and ensures subscriptions survive session restoration. Callback
229
+ fan-out is separately edge-only.
230
+ """
231
+ client = _make_client()
232
+ bridge = _FakeBridge(connected=True)
233
+ client._bridge = bridge
234
+ client._live = True # already online
235
+ calls: list[bool] = []
236
+ client.register_connection_callback(calls.append)
237
+
238
+ client._on_connection_change(True)
239
+
240
+ # Re-subscribe fires (side effect preserved) but no callback edge
241
+ assert len(bridge.subscribed_topics) == 1
242
+ assert calls == []
243
+
244
+
245
+ def _make_sentinel_snapshot() -> SpanPanelSnapshot:
246
+ """Build a minimal SpanPanelSnapshot for identity-assertion tests.
247
+
248
+ Field values are arbitrary — tests only assert object identity
249
+ (`snapshot is sentinel`), not content. If SpanPanelSnapshot grows
250
+ a new required field, add it here with a zero/empty default; no
251
+ test assertion needs to change.
252
+ """
253
+ return SpanPanelSnapshot(
254
+ serial_number="test-serial",
255
+ firmware_version="0.0.0",
256
+ main_relay_state="CLOSED",
257
+ instant_grid_power_w=0.0,
258
+ feedthrough_power_w=0.0,
259
+ main_meter_energy_consumed_wh=0.0,
260
+ main_meter_energy_produced_wh=0.0,
261
+ feedthrough_energy_consumed_wh=0.0,
262
+ feedthrough_energy_produced_wh=0.0,
263
+ dsm_state="DSM_GRID_OK",
264
+ current_run_config="PANEL_ON_GRID",
265
+ door_state="CLOSED",
266
+ proximity_proven=True,
267
+ uptime_s=0,
268
+ eth0_link=False,
269
+ wlan_link=False,
270
+ wwan_link=False,
271
+ panel_size=32,
272
+ )
273
+
274
+
275
+ class TestGetSnapshotLiveness:
276
+ """get_snapshot() must raise SpanPanelStaleDataError when not live."""
277
+
278
+ async def test_raises_stale_when_bridge_none(self) -> None:
279
+ client = _make_client()
280
+ client._bridge = None
281
+ client._homie = _FakeHomie(ready=True)
282
+
283
+ with pytest.raises(SpanPanelStaleDataError) as exc_info:
284
+ await client.get_snapshot()
285
+ assert "not connected" in str(exc_info.value).lower()
286
+
287
+ async def test_raises_stale_when_homie_none(self) -> None:
288
+ client = _make_client()
289
+ client._bridge = _FakeBridge(connected=True)
290
+ client._homie = None
291
+
292
+ with pytest.raises(SpanPanelStaleDataError) as exc_info:
293
+ await client.get_snapshot()
294
+ assert "not connected" in str(exc_info.value).lower()
295
+
296
+ async def test_raises_stale_when_broker_disconnected(self) -> None:
297
+ client = _make_client()
298
+ client._bridge = _FakeBridge(connected=False)
299
+ client._homie = _FakeHomie(ready=True)
300
+
301
+ with pytest.raises(SpanPanelStaleDataError) as exc_info:
302
+ await client.get_snapshot()
303
+ assert "broker" in str(exc_info.value).lower()
304
+
305
+ async def test_raises_stale_when_homie_not_ready(self) -> None:
306
+ client = _make_client()
307
+ client._bridge = _FakeBridge(connected=True)
308
+ client._homie = _FakeHomie(ready=False)
309
+
310
+ with pytest.raises(SpanPanelStaleDataError) as exc_info:
311
+ await client.get_snapshot()
312
+ assert "not ready" in str(exc_info.value).lower()
313
+
314
+ async def test_returns_snapshot_when_fully_live(self) -> None:
315
+ sentinel = _make_sentinel_snapshot()
316
+ client = _make_client()
317
+ client._bridge = _FakeBridge(connected=True)
318
+ client._homie = _FakeHomie(ready=True, snapshot=sentinel)
319
+
320
+ snapshot = await client.get_snapshot()
321
+ assert snapshot is sentinel
322
+
323
+ async def test_raised_exception_is_span_panel_error(self) -> None:
324
+ client = _make_client()
325
+ client._bridge = None
326
+ client._homie = None
327
+
328
+ with pytest.raises(SpanPanelError):
329
+ await client.get_snapshot()
330
+
331
+
332
+ class TestCloseBehavior:
333
+ """close() must reset internal state for safe re-use."""
334
+
335
+ async def test_close_resets_live_flag(self) -> None:
336
+ client = _make_client()
337
+ client._live = True # simulate a prior connection
338
+
339
+ await client.close()
340
+
341
+ assert client._live is False
342
+
343
+
344
+ class TestStaleSnapshotDispatchGuard:
345
+ """Post-disconnect stale-snapshot dispatch must not reach subscribers.
346
+
347
+ The library's debounced snapshot dispatch schedules a timer on every
348
+ incoming message. A timer scheduled just before a bridge disconnect
349
+ will still fire afterwards unless cancelled. Subscribers must never
350
+ receive a snapshot built after disconnect — see connection.py.
351
+ """
352
+
353
+ @pytest.mark.asyncio
354
+ async def test_dispatch_snapshot_bails_when_bridge_disconnected(self, caplog: pytest.LogCaptureFixture) -> None:
355
+ snapshot_sentinel = _make_sentinel_snapshot()
356
+ client = _make_client()
357
+ client._bridge = _FakeBridge(connected=False)
358
+ client._homie = _FakeHomie(ready=True, snapshot=snapshot_sentinel)
359
+
360
+ calls: list[SpanPanelSnapshot] = []
361
+
362
+ async def record(snapshot: SpanPanelSnapshot) -> None:
363
+ calls.append(snapshot)
364
+
365
+ client._snapshot_callbacks.append(record)
366
+
367
+ with caplog.at_level(logging.DEBUG, logger="span_panel_api.mqtt.client"):
368
+ await client._dispatch_snapshot()
369
+
370
+ assert calls == []
371
+ assert any("Skipping stale snapshot dispatch" in r.message for r in caplog.records)
372
+
373
+ @pytest.mark.asyncio
374
+ async def test_dispatch_snapshot_bails_when_homie_not_ready(self) -> None:
375
+ snapshot_sentinel = _make_sentinel_snapshot()
376
+ client = _make_client()
377
+ client._bridge = _FakeBridge(connected=True)
378
+ client._homie = _FakeHomie(ready=False, snapshot=snapshot_sentinel)
379
+
380
+ calls: list[SpanPanelSnapshot] = []
381
+
382
+ async def record(snapshot: SpanPanelSnapshot) -> None:
383
+ calls.append(snapshot)
384
+
385
+ client._snapshot_callbacks.append(record)
386
+
387
+ await client._dispatch_snapshot()
388
+
389
+ assert calls == []
390
+
391
+ @pytest.mark.asyncio
392
+ async def test_dispatch_snapshot_delivers_when_live(self) -> None:
393
+ snapshot_sentinel = _make_sentinel_snapshot()
394
+ client = _make_client()
395
+ client._bridge = _FakeBridge(connected=True)
396
+ client._homie = _FakeHomie(ready=True, snapshot=snapshot_sentinel)
397
+
398
+ calls: list[SpanPanelSnapshot] = []
399
+
400
+ async def record(snapshot: SpanPanelSnapshot) -> None:
401
+ calls.append(snapshot)
402
+
403
+ client._snapshot_callbacks.append(record)
404
+
405
+ await client._dispatch_snapshot()
406
+
407
+ assert calls == [snapshot_sentinel]
408
+
409
+ def test_on_connection_change_false_cancels_snapshot_timer(self) -> None:
410
+ """Disconnect must cancel any pending debounce timer."""
411
+ client = _make_client()
412
+ client._bridge = _FakeBridge(connected=True)
413
+ client._live = True # previously connected
414
+
415
+ # Simulate a scheduled debounce timer
416
+ fired: list[None] = []
417
+
418
+ class _FakeHandle:
419
+ cancelled = False
420
+
421
+ def cancel(self) -> None:
422
+ self.cancelled = True
423
+ fired.append(None)
424
+
425
+ handle = _FakeHandle()
426
+ client._snapshot_timer = handle # type: ignore[assignment]
427
+
428
+ client._on_connection_change(False)
429
+
430
+ assert handle.cancelled is True
431
+ assert client._snapshot_timer is None
@@ -18,7 +18,7 @@ from __future__ import annotations
18
18
 
19
19
  import json
20
20
  import time
21
- from unittest.mock import AsyncMock, MagicMock, patch
21
+ from unittest.mock import AsyncMock, MagicMock
22
22
 
23
23
  import pytest
24
24
 
@@ -39,6 +39,7 @@ from span_panel_api.mqtt.const import (
39
39
  TYPE_PV,
40
40
  )
41
41
  from span_panel_api.mqtt.accumulator import HomiePropertyAccumulator
42
+ from span_panel_api.mqtt.connection import AsyncMqttBridge
42
43
  from span_panel_api.mqtt.homie import HomieDeviceConsumer
43
44
  from span_panel_api.mqtt.models import MqttClientConfig
44
45
  from span_panel_api.protocol import (
@@ -46,6 +47,17 @@ from span_panel_api.protocol import (
46
47
  )
47
48
 
48
49
 
50
+ class _ConnectedBridge(AsyncMqttBridge):
51
+ """Minimal bridge stub: always reports connected. No I/O setup."""
52
+
53
+ def __init__(self) -> None: # noqa: D107
54
+ # Bypass AsyncMqttBridge.__init__ — avoids TLS/network setup.
55
+ pass
56
+
57
+ def is_connected(self) -> bool: # noqa: D102
58
+ return True
59
+
60
+
49
61
  SERIAL = "nj-2316-XXXX"
50
62
  PREFIX = f"{TOPIC_PREFIX}/{SERIAL}"
51
63
 
@@ -1091,6 +1103,7 @@ class TestSpanMqttClientSnapshot:
1091
1103
  client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config)
1092
1104
  client._accumulator = HomiePropertyAccumulator(SERIAL)
1093
1105
  client._homie = HomieDeviceConsumer(client._accumulator, panel_size=32)
1106
+ client._bridge = _ConnectedBridge()
1094
1107
 
1095
1108
  # Manually ready the homie consumer
1096
1109
  client._homie.handle_message(f"{PREFIX}/$state", "ready")
@@ -998,7 +998,7 @@ wheels = [
998
998
 
999
999
  [[package]]
1000
1000
  name = "pytest"
1001
- version = "9.0.2"
1001
+ version = "9.0.3"
1002
1002
  source = { registry = "https://pypi.org/simple" }
1003
1003
  dependencies = [
1004
1004
  { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1009,9 +1009,9 @@ dependencies = [
1009
1009
  { name = "pygments" },
1010
1010
  { name = "tomli", marker = "python_full_version < '3.11'" },
1011
1011
  ]
1012
- sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
1012
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
1013
1013
  wheels = [
1014
- { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
1014
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
1015
1015
  ]
1016
1016
 
1017
1017
  [[package]]
@@ -1292,7 +1292,7 @@ wheels = [
1292
1292
 
1293
1293
  [[package]]
1294
1294
  name = "span-panel-api"
1295
- version = "2.5.4"
1295
+ version = "2.6.0"
1296
1296
  source = { editable = "." }
1297
1297
  dependencies = [
1298
1298
  { name = "httpx" },
File without changes