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