span-panel-api 2.5.3__tar.gz → 2.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/CHANGELOG.md +64 -32
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/PKG-INFO +55 -16
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/README.md +54 -15
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/pyproject.toml +1 -1
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/__init__.py +2 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/exceptions.py +9 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/accumulator.py +4 -44
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/client.py +85 -7
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/homie.py +0 -7
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/protocol.py +2 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_accumulator.py +68 -86
- 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.3 → span_panel_api-2.6.0}/tests/test_mqtt_homie.py +32 -19
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/uv.lock +4 -22
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.codefactor +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.codefactor.yml +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.deps-installed +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.github/dependabot.yml +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.github/workflows/ci.yml +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.github/workflows/dependabot-auto-approve.yml +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.github/workflows/dependabot-auto-merge.yml +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.github/workflows/release.yml +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.gitignore +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.markdownlint-cli2.jsonc +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.markdownlint.json +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.pre-commit-config.yaml +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.prettierrc.json +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.vscode/extensions.json +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/.vscode/tasks.json +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/DEVELOPMENT.md +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/LICENSE +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/SECURITY.md +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/conftest.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/developer_attribute_readme.md +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/openapi.json +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/pytest.ini +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/pytest_output.log +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/scripts/__init__.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/scripts/coverage.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/scripts/format.sh +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/scripts/format_markdown.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/scripts/test_live_auth.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/setup-hooks.sh +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/auth.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/models.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/field_metadata.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/conftest.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/fixtures/v2/homie_schema.json +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_detection_auth.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-2.5.3 → span_panel_api-2.6.0}/tests/test_protocol_models.py +0 -0
|
@@ -4,17 +4,48 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
-
## [2.
|
|
7
|
+
## [2.6.0] - 04/2026
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`SpanMqttClient.register_connection_callback(cb)`** — subscribe to broker connection state transitions. Callback fires with `False` on broker disconnect and `True` on reconnect; returns an idempotent unregister function. Added to
|
|
12
|
+
`SpanPanelClientProtocol` so any transport that claims the protocol must implement it.
|
|
13
|
+
- **`SpanPanelStaleDataError`** exception — raised by `get_snapshot()` when the client is not fully live. Derives from `SpanPanelError` (not from `SpanPanelConnectionError`), because "never connected" and "running but data not currently live" are
|
|
14
|
+
semantically distinct states.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **`get_snapshot()` contract** — now raises `SpanPanelStaleDataError` when the bridge is not connected or the Homie device has not reached ready state. Previously, the method silently returned a snapshot built from whatever the in-memory accumulator
|
|
19
|
+
happened to hold, which made offline panels indistinguishable from online ones. This is the primary reason the span integration could not detect panel-offline transitions.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **Stale snapshot dispatch after bridge disconnect** — a pending snapshot-debounce timer scheduled just before a bridge disconnect could fire afterwards, delivering a snapshot built from the still-`ready()` accumulator to subscribers.
|
|
24
|
+
`_on_connection_change(False)` now cancels the pending timer, and `_dispatch_snapshot` is now guarded by the same liveness predicate as `get_snapshot()`, so push consumers never receive a post-disconnect stale snapshot.
|
|
25
|
+
|
|
26
|
+
### Breaking
|
|
27
|
+
|
|
28
|
+
- Consumers of `get_snapshot()` must now handle `SpanPanelStaleDataError`. Any consumer with a broad `except Exception` (or `except SpanPanelError`) branch already handles this correctly.
|
|
29
|
+
|
|
30
|
+
## [2.5.4] - 04/2026
|
|
31
|
+
|
|
32
|
+
### Reverted
|
|
33
|
+
|
|
34
|
+
- **Revert accumulator to 2.5.1 behavior** — the 2.5.2 lifecycle changes (property clearing, unconditional lifecycle transition on `$state=init`, generation counter) caused false energy dip spikes on panel reboots and network interruptions. The 2.5.3
|
|
35
|
+
partial fix (removing the clearing) was insufficient — the unconditional lifecycle disruption on transient `$state=init` events still triggered snapshot pipeline resets that produced 0.0 energy readings. Reverted `accumulator.py` and `homie.py` to their
|
|
36
|
+
stable 2.5.1 state. The existing dirty-node tracking handles reboot transitions correctly without special-case lifecycle management.
|
|
37
|
+
|
|
38
|
+
## [2.5.3] - 04/2026 (retired)
|
|
39
|
+
|
|
40
|
+
> **Retired:** Partial fix for 2.5.2 — removed property clearing but kept the lifecycle disruption that still caused false dips. Superseded by 2.5.4.
|
|
8
41
|
|
|
9
42
|
### Fixed
|
|
10
43
|
|
|
11
|
-
- **Preserve property values on lifecycle reset** — the
|
|
12
|
-
permanently inflated energy sensor values. Removed the property/timestamp/target clearing from `_handle_description()`. Pre-reboot values now serve as safe placeholders until the panel re-publishes fresh data.
|
|
13
|
-
- **Snapshot cache invalidated on reboot** — the generation counter still increments on lifecycle resets, forcing consumers to discard cached snapshots and rebuild from current accumulator state.
|
|
44
|
+
- **Preserve property values on lifecycle reset** — removed the property/timestamp/target clearing from `_handle_description()`.
|
|
14
45
|
|
|
15
46
|
## [2.5.2] - 04/2026 (retired)
|
|
16
47
|
|
|
17
|
-
> **Retired:**
|
|
48
|
+
> **Retired:** Lifecycle changes caused false energy dip spikes. Superseded by 2.5.4.
|
|
18
49
|
|
|
19
50
|
### Fixed
|
|
20
51
|
|
|
@@ -344,30 +375,31 @@ Package versions prior to 2.0.0 depend on the SPAN v1 REST API. SPAN will sunset
|
|
|
344
375
|
|
|
345
376
|
## Version History Summary
|
|
346
377
|
|
|
347
|
-
| Version | Date | Transport | Summary
|
|
348
|
-
| ---------- | ------- | ---------- |
|
|
349
|
-
| **2.5.
|
|
350
|
-
| **2.5.
|
|
351
|
-
| **2.5.
|
|
352
|
-
| **2.5.
|
|
353
|
-
| **2.
|
|
354
|
-
| **2.4.
|
|
355
|
-
| **2.4.
|
|
356
|
-
| **2.
|
|
357
|
-
| **2.3.
|
|
358
|
-
| **2.3.
|
|
359
|
-
| **2.
|
|
360
|
-
| **2.2.
|
|
361
|
-
| **2.
|
|
362
|
-
| **2.0.
|
|
363
|
-
| **2.0.
|
|
364
|
-
| **
|
|
365
|
-
| **1.1.
|
|
366
|
-
| **1.1.
|
|
367
|
-
| **1.1.
|
|
368
|
-
| **1.1.
|
|
369
|
-
| **1.1.
|
|
370
|
-
| **1.1.
|
|
371
|
-
| **1.1.
|
|
372
|
-
| **1.1.
|
|
373
|
-
| **1.1.
|
|
378
|
+
| Version | Date | Transport | Summary |
|
|
379
|
+
| ---------- | ------- | ---------- | ---------------------------------------------------------------------------------- |
|
|
380
|
+
| **2.5.4** | 04/2026 | MQTT/Homie | Revert accumulator to stable 2.5.1 behavior; fixes false energy dip spikes |
|
|
381
|
+
| **2.5.3** | 04/2026 | MQTT/Homie | _(retired)_ Partial fix — still caused false dips from lifecycle disruption |
|
|
382
|
+
| **2.5.2** | 04/2026 | MQTT/Homie | _(retired)_ Lifecycle changes caused false energy dip spikes |
|
|
383
|
+
| **2.5.1** | 04/2026 | MQTT/Homie | Replace assert with RuntimeError; fix bandit pre-commit hook |
|
|
384
|
+
| **2.5.0** | 03/2026 | MQTT/Homie | Homie accumulator layer, $target support, dirty-node snapshot caching |
|
|
385
|
+
| **2.4.2** | 03/2026 | MQTT/Homie | SSL context creation moved to executor |
|
|
386
|
+
| **2.4.1** | 03/2026 | MQTT/Homie | License metadata, loosened httpx constraint |
|
|
387
|
+
| **2.4.0** | 03/2026 | MQTT/Homie | proximityProven, injected HTTP client, executor file I/O, type alias, test cleanup |
|
|
388
|
+
| **2.3.2** | 03/2026 | MQTT/Homie | FQDN management endpoints |
|
|
389
|
+
| **2.3.1** | 03/2026 | MQTT/Homie | MQTT connection errors wrapped as SpanPanelConnectionError |
|
|
390
|
+
| **2.3.0** | 03/2026 | MQTT/Homie | Simulation engine removed |
|
|
391
|
+
| **2.2.4** | 03/2026 | MQTT/Homie | Negative zero fix on idle circuits |
|
|
392
|
+
| **2.2.3** | 03/2026 | MQTT/Homie | Panel size from Homie schema; `panel_size` always populated on snapshot |
|
|
393
|
+
| **2.0.2** | 03/2026 | MQTT/Homie | EVSE (EV charger) snapshot model, Homie parsing, simulation support |
|
|
394
|
+
| **2.0.1** | 03/2026 | MQTT/Homie | Full BESS metadata parsing, README documentation |
|
|
395
|
+
| **2.0.0** | 02/2026 | MQTT/Homie | Ground-up rewrite: MQTT-only, protocol-based API, real-time push, PV/BESS metadata |
|
|
396
|
+
| **1.1.14** | 12/2025 | REST | Keep-Alive and RemoteProtocolError handling |
|
|
397
|
+
| **1.1.9** | 9/2025 | REST | Simulation sign corrections |
|
|
398
|
+
| **1.1.8** | 2024 | REST | Simulation power sign fix |
|
|
399
|
+
| **1.1.6** | 2024 | REST | YAML simulation API, battery simulation |
|
|
400
|
+
| **1.1.5** | 2024 | REST | Simulation edge cases |
|
|
401
|
+
| **1.1.4** | 2024 | REST | Formatting and linting |
|
|
402
|
+
| **1.1.3** | 2024 | REST | Test and lint fixes |
|
|
403
|
+
| **1.1.2** | 2024 | REST | Simulation mode added |
|
|
404
|
+
| **1.1.1** | 2024 | REST | Dependency updates |
|
|
405
|
+
| **1.1.0** | 2024 | REST | Initial release |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 2.
|
|
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
|
+
"""
|
|
@@ -56,11 +56,6 @@ class HomiePropertyAccumulator:
|
|
|
56
56
|
# Node type mapping from $description
|
|
57
57
|
self._node_types: dict[str, str] = {}
|
|
58
58
|
|
|
59
|
-
# Generation counter — incremented whenever a new lifecycle's
|
|
60
|
-
# $description is accepted (including initial boot) so consumers
|
|
61
|
-
# can invalidate snapshot caches built from prior-lifecycle data.
|
|
62
|
-
self._generation: int = 0
|
|
63
|
-
|
|
64
59
|
# Dirty tracking
|
|
65
60
|
self._dirty_nodes: set[str] = set()
|
|
66
61
|
|
|
@@ -84,11 +79,6 @@ class HomiePropertyAccumulator:
|
|
|
84
79
|
"""Monotonic timestamp of the last READY transition, 0.0 if never ready."""
|
|
85
80
|
return self._ready_since
|
|
86
81
|
|
|
87
|
-
@property
|
|
88
|
-
def generation(self) -> int:
|
|
89
|
-
"""Counter incremented on each new lifecycle's ``$description`` to invalidate consumer caches."""
|
|
90
|
-
return self._generation
|
|
91
|
-
|
|
92
82
|
def is_ready(self) -> bool:
|
|
93
83
|
"""True when lifecycle is READY."""
|
|
94
84
|
return self._lifecycle == HomieLifecycle.READY
|
|
@@ -201,17 +191,10 @@ class HomiePropertyAccumulator:
|
|
|
201
191
|
self._received_state_ready = False
|
|
202
192
|
self._received_description = False
|
|
203
193
|
else:
|
|
204
|
-
# init, sleeping, alert, etc. — connected but not ready
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
#
|
|
208
|
-
# Reset _received_description so that the upcoming $description
|
|
209
|
-
# triggers a property clear. This covers fast reboots where
|
|
210
|
-
# the broker's LWT ($state=disconnected) may not reach us
|
|
211
|
-
# before the panel publishes $state=init.
|
|
212
|
-
self._lifecycle = HomieLifecycle.CONNECTED
|
|
194
|
+
# init, sleeping, alert, etc. — connected but not ready
|
|
195
|
+
if self._lifecycle == HomieLifecycle.DISCONNECTED:
|
|
196
|
+
self._lifecycle = HomieLifecycle.CONNECTED
|
|
213
197
|
self._received_state_ready = False
|
|
214
|
-
self._received_description = False
|
|
215
198
|
|
|
216
199
|
_LOGGER.debug("Homie $state: %s → lifecycle=%s", payload, self._lifecycle.value)
|
|
217
200
|
|
|
@@ -223,25 +206,6 @@ class HomiePropertyAccumulator:
|
|
|
223
206
|
_LOGGER.warning("Invalid $description JSON")
|
|
224
207
|
return
|
|
225
208
|
|
|
226
|
-
# _handle_state() already reset _received_description to False due to
|
|
227
|
-
# a state change that starts a new panel lifecycle, including
|
|
228
|
-
# $state=disconnected/lost and other non-ready states such as init.
|
|
229
|
-
# Increment the generation counter to invalidate consumer snapshot
|
|
230
|
-
# caches, but preserve property values — pre-reboot readings serve
|
|
231
|
-
# as safe placeholders until the panel re-publishes. Clearing values
|
|
232
|
-
# would emit 0.0 for energy counters via _parse_float(""), triggering
|
|
233
|
-
# false dip-compensation offsets in the integration.
|
|
234
|
-
#
|
|
235
|
-
# On a pure MQTT reconnect (no panel reboot), _received_description
|
|
236
|
-
# is still True from the previous session so we skip this block —
|
|
237
|
-
# the retained property messages carry the correct (unchanged) values.
|
|
238
|
-
if not self._received_description:
|
|
239
|
-
self._generation += 1
|
|
240
|
-
_LOGGER.debug(
|
|
241
|
-
"Panel reboot detected (generation %d); preserving property values as placeholders",
|
|
242
|
-
self._generation,
|
|
243
|
-
)
|
|
244
|
-
|
|
245
209
|
self._received_description = True
|
|
246
210
|
self._node_types.clear()
|
|
247
211
|
|
|
@@ -256,11 +220,7 @@ class HomiePropertyAccumulator:
|
|
|
256
220
|
# Mark all known nodes dirty
|
|
257
221
|
self._dirty_nodes.update(self._node_types.keys())
|
|
258
222
|
|
|
259
|
-
_LOGGER.debug(
|
|
260
|
-
"Parsed $description with %d nodes (generation %d)",
|
|
261
|
-
len(self._node_types),
|
|
262
|
-
self._generation,
|
|
263
|
-
)
|
|
223
|
+
_LOGGER.debug("Parsed $description with %d nodes", len(self._node_types))
|
|
264
224
|
|
|
265
225
|
# Lifecycle transition
|
|
266
226
|
if self._received_state_ready:
|
|
@@ -9,10 +9,11 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
11
|
from collections.abc import Awaitable, Callable
|
|
12
|
+
import contextlib
|
|
12
13
|
import logging
|
|
13
14
|
|
|
14
15
|
from ..auth import get_homie_schema
|
|
15
|
-
from ..exceptions import SpanPanelConnectionError, SpanPanelServerError
|
|
16
|
+
from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError
|
|
16
17
|
from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot
|
|
17
18
|
from ..protocol import PanelCapability
|
|
18
19
|
from .accumulator import HomiePropertyAccumulator
|
|
@@ -52,6 +53,8 @@ class SpanMqttClient:
|
|
|
52
53
|
self._homie: HomieDeviceConsumer | None = None
|
|
53
54
|
self._streaming = False
|
|
54
55
|
self._snapshot_callbacks: list[Callable[[SpanPanelSnapshot], Awaitable[None]]] = []
|
|
56
|
+
self._connection_callbacks: list[Callable[[bool], None]] = []
|
|
57
|
+
self._live = False
|
|
55
58
|
self._ready_event: asyncio.Event | None = None
|
|
56
59
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
57
60
|
self._background_tasks: set[asyncio.Task[None]] = set()
|
|
@@ -190,6 +193,7 @@ class SpanMqttClient:
|
|
|
190
193
|
await self._bridge.disconnect()
|
|
191
194
|
self._bridge = None
|
|
192
195
|
self._accumulator = None
|
|
196
|
+
self._live = False
|
|
193
197
|
|
|
194
198
|
async def ping(self) -> bool:
|
|
195
199
|
"""Check if MQTT connection is alive and device is ready."""
|
|
@@ -197,12 +201,43 @@ class SpanMqttClient:
|
|
|
197
201
|
return False
|
|
198
202
|
return self._bridge.is_connected() and self._homie.is_ready()
|
|
199
203
|
|
|
204
|
+
def register_connection_callback(self, callback: Callable[[bool], None]) -> Callable[[], None]:
|
|
205
|
+
"""Subscribe to broker connection state transitions.
|
|
206
|
+
|
|
207
|
+
Callback fires with False on broker disconnect and True on reconnect.
|
|
208
|
+
No synthetic call is made at registration time — callbacks only fire
|
|
209
|
+
on real state edges. To check current connection state on registration,
|
|
210
|
+
await ping().
|
|
211
|
+
|
|
212
|
+
Returns an unregister function that removes the callback from the
|
|
213
|
+
dispatch list. Calling unregister twice is safe.
|
|
214
|
+
"""
|
|
215
|
+
self._connection_callbacks.append(callback)
|
|
216
|
+
|
|
217
|
+
def unregister() -> None:
|
|
218
|
+
with contextlib.suppress(ValueError):
|
|
219
|
+
self._connection_callbacks.remove(callback)
|
|
220
|
+
|
|
221
|
+
return unregister
|
|
222
|
+
|
|
200
223
|
async def get_snapshot(self) -> SpanPanelSnapshot:
|
|
201
224
|
"""Return current snapshot from accumulated MQTT state.
|
|
202
225
|
|
|
203
|
-
|
|
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)
|
|
@@ -64,7 +64,6 @@ class HomieDeviceConsumer:
|
|
|
64
64
|
self._acc = accumulator
|
|
65
65
|
self._panel_size = panel_size
|
|
66
66
|
self._cached_snapshot: SpanPanelSnapshot | None = None
|
|
67
|
-
self._cache_generation: int = 0
|
|
68
67
|
|
|
69
68
|
# -- Delegation to accumulator -------------------------------------------
|
|
70
69
|
# These thin wrappers allow SpanMqttClient (and legacy test code) to
|
|
@@ -119,12 +118,6 @@ class HomieDeviceConsumer:
|
|
|
119
118
|
|
|
120
119
|
Must be called after accumulator is_ready() returns True.
|
|
121
120
|
"""
|
|
122
|
-
# Invalidate cache when the accumulator generation advances
|
|
123
|
-
# (panel reboot cleared all property values).
|
|
124
|
-
if self._acc.generation != self._cache_generation:
|
|
125
|
-
self._cached_snapshot = None
|
|
126
|
-
self._cache_generation = self._acc.generation
|
|
127
|
-
|
|
128
121
|
dirty = self._acc.dirty_node_ids()
|
|
129
122
|
|
|
130
123
|
if not dirty and self._cached_snapshot is not None:
|
|
@@ -46,6 +46,8 @@ class SpanPanelClientProtocol(Protocol):
|
|
|
46
46
|
|
|
47
47
|
async def get_snapshot(self) -> SpanPanelSnapshot: ...
|
|
48
48
|
|
|
49
|
+
def register_connection_callback(self, callback: Callable[[bool], None]) -> Callable[[], None]: ...
|
|
50
|
+
|
|
49
51
|
|
|
50
52
|
@runtime_checkable
|
|
51
53
|
class CircuitControlProtocol(Protocol):
|