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.
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/CHANGELOG.md +23 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/PKG-INFO +55 -16
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/README.md +54 -15
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/pyproject.toml +1 -1
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/__init__.py +2 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/exceptions.py +9 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/client.py +85 -7
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/protocol.py +2 -0
- span_panel_api-2.6.0/tests/test_exceptions.py +23 -0
- span_panel_api-2.6.0/tests/test_mqtt_client_connection.py +431 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_mqtt_homie.py +14 -1
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/uv.lock +4 -4
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.codefactor +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.codefactor.yml +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.deps-installed +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.github/dependabot.yml +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.github/workflows/ci.yml +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.github/workflows/dependabot-auto-approve.yml +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.github/workflows/dependabot-auto-merge.yml +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.github/workflows/release.yml +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.gitignore +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.markdownlint-cli2.jsonc +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.markdownlint.json +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.pre-commit-config.yaml +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.prettierrc.json +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.vscode/extensions.json +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/.vscode/tasks.json +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/DEVELOPMENT.md +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/LICENSE +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/SECURITY.md +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/conftest.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/developer_attribute_readme.md +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/openapi.json +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/pytest.ini +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/pytest_output.log +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/scripts/__init__.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/scripts/coverage.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/scripts/format.sh +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/scripts/format_markdown.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/scripts/test_live_auth.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/setup-hooks.sh +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/auth.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/models.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/accumulator.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/field_metadata.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/homie.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/conftest.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/fixtures/v2/homie_schema.json +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_accumulator.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_detection_auth.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/test_protocol_conformance.py +0 -0
- {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.
|
|
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
|
-
| `
|
|
318
|
-
| `
|
|
319
|
-
| `
|
|
320
|
-
| `
|
|
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
|
|
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
|
-
| `
|
|
303
|
-
| `
|
|
304
|
-
| `
|
|
305
|
-
| `
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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/
|
|
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/
|
|
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.
|
|
1295
|
+
version = "2.6.0"
|
|
1296
1296
|
source = { editable = "." }
|
|
1297
1297
|
dependencies = [
|
|
1298
1298
|
{ name = "httpx" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-2.5.4 → span_panel_api-2.6.0}/tests/simulation_fixtures/circuits.response.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|