span-panel-api 2.4.2__tar.gz → 2.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/CHANGELOG.md +16 -0
  2. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/PKG-INFO +80 -17
  3. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/README.md +79 -16
  4. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/pyproject.toml +2 -2
  5. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/__init__.py +4 -2
  6. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/models.py +2 -0
  7. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/__init__.py +3 -0
  8. span_panel_api-2.5.0/src/span_panel_api/mqtt/accumulator.py +273 -0
  9. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/client.py +5 -1
  10. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/homie.py +181 -211
  11. span_panel_api-2.5.0/tests/test_accumulator.py +439 -0
  12. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_auth_and_homie_helpers.py +3 -1
  13. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_mqtt_homie.py +357 -274
  14. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/uv.lock +1 -1
  15. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.codefactor +0 -0
  16. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.codefactor.yml +0 -0
  17. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.deps-installed +0 -0
  18. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  19. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  20. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.github/dependabot.yml +0 -0
  21. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.github/workflows/ci.yml +0 -0
  22. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.github/workflows/dependabot-auto-approve.yml +0 -0
  23. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.github/workflows/dependabot-auto-merge.yml +0 -0
  24. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.github/workflows/release.yml +0 -0
  25. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.gitignore +0 -0
  26. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.markdownlint-cli2.jsonc +0 -0
  27. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.markdownlint.json +0 -0
  28. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.pre-commit-config.yaml +0 -0
  29. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.prettierrc.json +0 -0
  30. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.vscode/extensions.json +0 -0
  31. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.vscode/tasks.json +0 -0
  32. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/DEVELOPMENT.md +0 -0
  33. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/LICENSE +0 -0
  34. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/SECURITY.md +0 -0
  35. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/conftest.py +0 -0
  36. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/coverage_output.log +0 -0
  37. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/debug_unmapped.py +0 -0
  38. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/developer_attribute_readme.md +0 -0
  39. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/openapi.json +0 -0
  40. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/pytest.ini +0 -0
  41. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/pytest_output.log +0 -0
  42. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/scripts/__init__.py +0 -0
  43. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/scripts/coverage.py +0 -0
  44. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/scripts/format.sh +0 -0
  45. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/scripts/format_markdown.py +0 -0
  46. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/scripts/test_live_auth.py +0 -0
  47. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/setup-hooks.sh +0 -0
  48. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/_http.py +0 -0
  49. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/auth.py +0 -0
  50. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/const.py +0 -0
  51. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/detection.py +0 -0
  52. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/exceptions.py +0 -0
  53. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/factory.py +0 -0
  54. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  55. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/connection.py +0 -0
  56. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/const.py +0 -0
  57. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/field_metadata.py +0 -0
  58. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/models.py +0 -0
  59. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/phase_validation.py +0 -0
  60. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/protocol.py +0 -0
  61. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/py.typed +0 -0
  62. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/conftest.py +0 -0
  63. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  64. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  65. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  66. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/fixtures/v2/README.md +0 -0
  67. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/fixtures/v2/homie_schema.json +0 -0
  68. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/fixtures/v2/status.json +0 -0
  69. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  70. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  71. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  72. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/simulation_fixtures/status.response.txt +0 -0
  73. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_async_mqtt_client.py +0 -0
  74. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_detection_auth.py +0 -0
  75. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_field_metadata.py +0 -0
  76. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_mqtt_bridge.py +0 -0
  77. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_mqtt_connect_flow.py +0 -0
  78. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_mqtt_debounce.py +0 -0
  79. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_phase_validation_configs.py +0 -0
  80. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_phase_validation_errors.py +0 -0
  81. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_protocol_conformance.py +0 -0
  82. {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_protocol_models.py +0 -0
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.5.0] - 03/2026
8
+
9
+ ### Added
10
+
11
+ - **`HomiePropertyAccumulator`** — new layer that handles generic Homie v5 protocol parsing (message routing, property/target storage, dirty-node tracking) with an explicit lifecycle state machine (`HomieLifecycle`), cleanly separated from SPAN-specific
12
+ snapshot construction.
13
+ - **`$target` property support** — `SpanCircuitSnapshot` gains `relay_state_target` and `priority_target` fields, surfacing the desired-vs-actual state for relay and shed-priority commands.
14
+ - **Dirty-node snapshot caching** — `HomieDeviceConsumer.build_snapshot()` tracks which nodes changed since the last build and returns a cached snapshot when nothing is dirty, reducing per-scan CPU cost on constrained hardware.
15
+
16
+ ### Changed
17
+
18
+ - **Layered Homie consumer architecture** — `HomieDeviceConsumer` no longer handles protocol plumbing. It reads from `HomiePropertyAccumulator` via a query API (`get_prop`, `get_target`, `nodes_by_type`, etc.) and focuses solely on SPAN domain
19
+ interpretation: power sign normalization, DSM derivation, unmapped tab synthesis, and snapshot assembly.
20
+ - **`SpanMqttClient` composes both layers** — `connect()` creates an accumulator and wires it into the consumer. The public client API is unchanged.
21
+ - **Property callbacks fire only on value change** — retained messages replaying already-known values no longer trigger callback storms on MQTT reconnect.
22
+
7
23
  ## [2.4.2] - 03/2026
8
24
 
9
25
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: span-panel-api
3
- Version: 2.4.2
3
+ Version: 2.5.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
@@ -46,14 +46,18 @@ pip install span-panel-api
46
46
 
47
47
  - `httpx` — v2 authentication and detection endpoints
48
48
  - `paho-mqtt` — MQTT/Homie transport (real-time push)
49
- - `pyyaml` — simulation configuration
49
+ - `pyyaml` — YAML parsing for configuration and API payloads
50
50
 
51
51
  ## Architecture
52
52
 
53
53
  ### Transport
54
54
 
55
- The `SpanMqttClient` connects to the panel's MQTT broker (MQTTS or WebSocket) and subscribes to the Homie device tree. A `HomieDeviceConsumer` state machine parses incoming topic updates into typed `SpanPanelSnapshot` dataclasses. Changes are pushed to
56
- consumers via callbacks.
55
+ The `SpanMqttClient` connects to the panel's MQTT broker (MQTTS or WebSocket) and subscribes to the Homie device tree. A two-layer architecture separates generic Homie v5 protocol handling from SPAN-specific interpretation:
56
+
57
+ - **`HomiePropertyAccumulator`** — handles message routing, property and `$target` storage, dirty-node tracking, and an explicit lifecycle state machine (`HomieLifecycle`). Protocol-only; no SPAN domain knowledge.
58
+ - **`HomieDeviceConsumer`** — reads from the accumulator via a query API and builds typed `SpanPanelSnapshot` dataclasses. Handles power sign normalization, DSM derivation, unmapped tab synthesis, and dirty-node-aware snapshot caching.
59
+
60
+ Changes are pushed to consumers via callbacks. Dirty-node tracking allows the snapshot builder to skip unchanged nodes, reducing per-scan CPU cost on constrained hardware.
57
61
 
58
62
  ### Event-Loop-Driven I/O (Home Assistant Compatible)
59
63
 
@@ -86,6 +90,7 @@ The library defines three structural subtyping protocols (PEP 544) that both the
86
90
  | -------------------------- | ------------------------------------------------------------------------------------- |
87
91
  | `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot` |
88
92
  | `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` |
93
+ | `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` |
89
94
  | `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` |
90
95
 
91
96
  Integration code programs against these protocols, not transport-specific classes.
@@ -97,7 +102,7 @@ All panel state is represented as immutable, frozen dataclasses:
97
102
  | Dataclass | Content |
98
103
  | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
99
104
  | `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, circuits, battery, PV, EVSE |
100
- | `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current |
105
+ | `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current, `$target` pending state |
101
106
  | `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh, vendor/product metadata, nameplate capacity |
102
107
  | `SpanPVSnapshot` | PV inverter: vendor/product metadata, nameplate capacity |
103
108
  | `SpanEvseSnapshot` | EVSE (EV charger): status, lock state, advertised current, vendor/product/serial/version metadata |
@@ -193,6 +198,40 @@ client = await create_span_client(
193
198
  )
194
199
  ```
195
200
 
201
+ ### Direct Client Construction
202
+
203
+ Consumers that manage their own registration and broker configuration can instantiate `SpanMqttClient` directly:
204
+
205
+ ```python
206
+ from span_panel_api import SpanMqttClient, MqttClientConfig
207
+
208
+ config = MqttClientConfig(
209
+ broker_host="192.168.1.100",
210
+ username="stored-username",
211
+ password="stored-password",
212
+ mqtts_port=8883,
213
+ ws_port=9001,
214
+ wss_port=443,
215
+ )
216
+
217
+ client = SpanMqttClient(
218
+ host="192.168.1.100",
219
+ serial_number="nj-2316-XXXX",
220
+ broker_config=config,
221
+ snapshot_interval=1.0,
222
+ )
223
+ await client.connect()
224
+ ```
225
+
226
+ ### Scan Frequency
227
+
228
+ `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
+
230
+ ```python
231
+ # Reduce snapshot frequency to every 2 seconds
232
+ client.set_snapshot_interval(2.0)
233
+ ```
234
+
196
235
  ### Circuit Control
197
236
 
198
237
  ```python
@@ -204,6 +243,18 @@ await client.set_circuit_relay("circuit-uuid", "CLOSED")
204
243
  await client.set_circuit_priority("circuit-uuid", "NEVER")
205
244
  ```
206
245
 
246
+ ### Pending-State Detection
247
+
248
+ When the panel publishes Homie `$target` properties, `SpanCircuitSnapshot` exposes the desired state alongside the actual state:
249
+
250
+ ```python
251
+ for cid, circuit in snapshot.circuits.items():
252
+ if circuit.relay_state_target and circuit.relay_state_target != circuit.relay_state:
253
+ print(f" {circuit.name}: relay transitioning {circuit.relay_state} → {circuit.relay_state_target}")
254
+ if circuit.priority_target and circuit.priority_target != circuit.priority:
255
+ print(f" {circuit.name}: priority pending {circuit.priority} → {circuit.priority_target}")
256
+ ```
257
+
207
258
  ### API Version Detection
208
259
 
209
260
  Detect whether a panel supports v2 (unauthenticated probe):
@@ -223,7 +274,11 @@ if result.status_info:
223
274
  Standalone async functions for v2-specific HTTP operations:
224
275
 
225
276
  ```python
226
- from span_panel_api import register_v2, download_ca_cert, get_homie_schema, regenerate_passphrase
277
+ from span_panel_api import (
278
+ register_v2, download_ca_cert, get_homie_schema,
279
+ regenerate_passphrase, get_v2_status,
280
+ register_fqdn, get_fqdn, delete_fqdn,
281
+ )
227
282
 
228
283
  # Register and obtain MQTT broker credentials
229
284
  auth = await register_v2("192.168.1.100", "my-app", passphrase="panel-passphrase")
@@ -240,21 +295,29 @@ print(f"Schema hash: {schema.types_schema_hash}")
240
295
 
241
296
  # Rotate MQTT broker password (invalidates previous password)
242
297
  new_password = await regenerate_passphrase("192.168.1.100", token=auth.access_token)
298
+
299
+ # Get panel status (unauthenticated)
300
+ status = await get_v2_status("192.168.1.100")
301
+ print(f"Serial: {status.serial_number}, Firmware: {status.firmware_version}")
302
+
303
+ # FQDN management (for panel TLS certificate SAN)
304
+ await register_fqdn("192.168.1.100", "panel.local", token=auth.access_token)
305
+ fqdn = await get_fqdn("192.168.1.100", token=auth.access_token)
306
+ await delete_fqdn("192.168.1.100", token=auth.access_token)
243
307
  ```
244
308
 
245
309
  ## Error Handling
246
310
 
247
311
  All exceptions inherit from `SpanPanelError`:
248
312
 
249
- | Exception | Cause |
250
- | ------------------------------ | --------------------------------------------------------- |
251
- | `SpanPanelAuthError` | Invalid passphrase, expired token, or missing credentials |
252
- | `SpanPanelConnectionError` | Cannot reach the panel (network/DNS) |
253
- | `SpanPanelTimeoutError` | Request or connection timed out |
254
- | `SpanPanelValidationError` | Data validation failure |
255
- | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
256
- | `SpanPanelServerError` | Panel returned HTTP 500 |
257
- | `SimulationConfigurationError` | Invalid simulation YAML configuration |
313
+ | Exception | Cause |
314
+ | -------------------------- | --------------------------------------------------------- |
315
+ | `SpanPanelAuthError` | Invalid passphrase, expired token, or missing credentials |
316
+ | `SpanPanelConnectionError` | Cannot reach the panel (network/DNS) |
317
+ | `SpanPanelTimeoutError` | Request or connection timed out |
318
+ | `SpanPanelValidationError` | Data validation failure |
319
+ | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
320
+ | `SpanPanelServerError` | Panel returned HTTP 500 |
258
321
 
259
322
  ```python
260
323
  from span_panel_api import SpanPanelAuthError, SpanPanelConnectionError
@@ -291,14 +354,14 @@ src/span_panel_api/
291
354
  ├── models.py # Snapshot dataclasses (panel, circuit, battery, PV)
292
355
  ├── phase_validation.py # Electrical phase utilities
293
356
  ├── protocol.py # PEP 544 protocols + PanelCapability flags
294
- ├── simulation.py # Simulation engine (YAML-driven, snapshot-producing)
295
357
  └── mqtt/
296
358
  ├── __init__.py
359
+ ├── accumulator.py # HomiePropertyAccumulator (Homie v5 protocol layer)
297
360
  ├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern)
298
361
  ├── client.py # SpanMqttClient (all three protocols)
299
362
  ├── connection.py # AsyncMqttBridge (event-loop-driven, no threads)
300
363
  ├── const.py # MQTT/Homie constants + UUID helpers
301
- ├── homie.py # HomieDeviceConsumer (Homie v5 state machine)
364
+ ├── homie.py # HomieDeviceConsumer (SPAN snapshot builder)
302
365
  └── models.py # MqttClientConfig, MqttTransport
303
366
  ```
304
367
 
@@ -31,14 +31,18 @@ pip install span-panel-api
31
31
 
32
32
  - `httpx` — v2 authentication and detection endpoints
33
33
  - `paho-mqtt` — MQTT/Homie transport (real-time push)
34
- - `pyyaml` — simulation configuration
34
+ - `pyyaml` — YAML parsing for configuration and API payloads
35
35
 
36
36
  ## Architecture
37
37
 
38
38
  ### Transport
39
39
 
40
- The `SpanMqttClient` connects to the panel's MQTT broker (MQTTS or WebSocket) and subscribes to the Homie device tree. A `HomieDeviceConsumer` state machine parses incoming topic updates into typed `SpanPanelSnapshot` dataclasses. Changes are pushed to
41
- consumers via callbacks.
40
+ The `SpanMqttClient` connects to the panel's MQTT broker (MQTTS or WebSocket) and subscribes to the Homie device tree. A two-layer architecture separates generic Homie v5 protocol handling from SPAN-specific interpretation:
41
+
42
+ - **`HomiePropertyAccumulator`** — handles message routing, property and `$target` storage, dirty-node tracking, and an explicit lifecycle state machine (`HomieLifecycle`). Protocol-only; no SPAN domain knowledge.
43
+ - **`HomieDeviceConsumer`** — reads from the accumulator via a query API and builds typed `SpanPanelSnapshot` dataclasses. Handles power sign normalization, DSM derivation, unmapped tab synthesis, and dirty-node-aware snapshot caching.
44
+
45
+ Changes are pushed to consumers via callbacks. Dirty-node tracking allows the snapshot builder to skip unchanged nodes, reducing per-scan CPU cost on constrained hardware.
42
46
 
43
47
  ### Event-Loop-Driven I/O (Home Assistant Compatible)
44
48
 
@@ -71,6 +75,7 @@ The library defines three structural subtyping protocols (PEP 544) that both the
71
75
  | -------------------------- | ------------------------------------------------------------------------------------- |
72
76
  | `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot` |
73
77
  | `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` |
78
+ | `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` |
74
79
  | `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` |
75
80
 
76
81
  Integration code programs against these protocols, not transport-specific classes.
@@ -82,7 +87,7 @@ All panel state is represented as immutable, frozen dataclasses:
82
87
  | Dataclass | Content |
83
88
  | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
84
89
  | `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, circuits, battery, PV, EVSE |
85
- | `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current |
90
+ | `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current, `$target` pending state |
86
91
  | `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh, vendor/product metadata, nameplate capacity |
87
92
  | `SpanPVSnapshot` | PV inverter: vendor/product metadata, nameplate capacity |
88
93
  | `SpanEvseSnapshot` | EVSE (EV charger): status, lock state, advertised current, vendor/product/serial/version metadata |
@@ -178,6 +183,40 @@ client = await create_span_client(
178
183
  )
179
184
  ```
180
185
 
186
+ ### Direct Client Construction
187
+
188
+ Consumers that manage their own registration and broker configuration can instantiate `SpanMqttClient` directly:
189
+
190
+ ```python
191
+ from span_panel_api import SpanMqttClient, MqttClientConfig
192
+
193
+ config = MqttClientConfig(
194
+ broker_host="192.168.1.100",
195
+ username="stored-username",
196
+ password="stored-password",
197
+ mqtts_port=8883,
198
+ ws_port=9001,
199
+ wss_port=443,
200
+ )
201
+
202
+ client = SpanMqttClient(
203
+ host="192.168.1.100",
204
+ serial_number="nj-2316-XXXX",
205
+ broker_config=config,
206
+ snapshot_interval=1.0,
207
+ )
208
+ await client.connect()
209
+ ```
210
+
211
+ ### Scan Frequency
212
+
213
+ `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
+
215
+ ```python
216
+ # Reduce snapshot frequency to every 2 seconds
217
+ client.set_snapshot_interval(2.0)
218
+ ```
219
+
181
220
  ### Circuit Control
182
221
 
183
222
  ```python
@@ -189,6 +228,18 @@ await client.set_circuit_relay("circuit-uuid", "CLOSED")
189
228
  await client.set_circuit_priority("circuit-uuid", "NEVER")
190
229
  ```
191
230
 
231
+ ### Pending-State Detection
232
+
233
+ When the panel publishes Homie `$target` properties, `SpanCircuitSnapshot` exposes the desired state alongside the actual state:
234
+
235
+ ```python
236
+ for cid, circuit in snapshot.circuits.items():
237
+ if circuit.relay_state_target and circuit.relay_state_target != circuit.relay_state:
238
+ print(f" {circuit.name}: relay transitioning {circuit.relay_state} → {circuit.relay_state_target}")
239
+ if circuit.priority_target and circuit.priority_target != circuit.priority:
240
+ print(f" {circuit.name}: priority pending {circuit.priority} → {circuit.priority_target}")
241
+ ```
242
+
192
243
  ### API Version Detection
193
244
 
194
245
  Detect whether a panel supports v2 (unauthenticated probe):
@@ -208,7 +259,11 @@ if result.status_info:
208
259
  Standalone async functions for v2-specific HTTP operations:
209
260
 
210
261
  ```python
211
- from span_panel_api import register_v2, download_ca_cert, get_homie_schema, regenerate_passphrase
262
+ from span_panel_api import (
263
+ register_v2, download_ca_cert, get_homie_schema,
264
+ regenerate_passphrase, get_v2_status,
265
+ register_fqdn, get_fqdn, delete_fqdn,
266
+ )
212
267
 
213
268
  # Register and obtain MQTT broker credentials
214
269
  auth = await register_v2("192.168.1.100", "my-app", passphrase="panel-passphrase")
@@ -225,21 +280,29 @@ print(f"Schema hash: {schema.types_schema_hash}")
225
280
 
226
281
  # Rotate MQTT broker password (invalidates previous password)
227
282
  new_password = await regenerate_passphrase("192.168.1.100", token=auth.access_token)
283
+
284
+ # Get panel status (unauthenticated)
285
+ status = await get_v2_status("192.168.1.100")
286
+ print(f"Serial: {status.serial_number}, Firmware: {status.firmware_version}")
287
+
288
+ # FQDN management (for panel TLS certificate SAN)
289
+ await register_fqdn("192.168.1.100", "panel.local", token=auth.access_token)
290
+ fqdn = await get_fqdn("192.168.1.100", token=auth.access_token)
291
+ await delete_fqdn("192.168.1.100", token=auth.access_token)
228
292
  ```
229
293
 
230
294
  ## Error Handling
231
295
 
232
296
  All exceptions inherit from `SpanPanelError`:
233
297
 
234
- | Exception | Cause |
235
- | ------------------------------ | --------------------------------------------------------- |
236
- | `SpanPanelAuthError` | Invalid passphrase, expired token, or missing credentials |
237
- | `SpanPanelConnectionError` | Cannot reach the panel (network/DNS) |
238
- | `SpanPanelTimeoutError` | Request or connection timed out |
239
- | `SpanPanelValidationError` | Data validation failure |
240
- | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
241
- | `SpanPanelServerError` | Panel returned HTTP 500 |
242
- | `SimulationConfigurationError` | Invalid simulation YAML configuration |
298
+ | Exception | Cause |
299
+ | -------------------------- | --------------------------------------------------------- |
300
+ | `SpanPanelAuthError` | Invalid passphrase, expired token, or missing credentials |
301
+ | `SpanPanelConnectionError` | Cannot reach the panel (network/DNS) |
302
+ | `SpanPanelTimeoutError` | Request or connection timed out |
303
+ | `SpanPanelValidationError` | Data validation failure |
304
+ | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
305
+ | `SpanPanelServerError` | Panel returned HTTP 500 |
243
306
 
244
307
  ```python
245
308
  from span_panel_api import SpanPanelAuthError, SpanPanelConnectionError
@@ -276,14 +339,14 @@ src/span_panel_api/
276
339
  ├── models.py # Snapshot dataclasses (panel, circuit, battery, PV)
277
340
  ├── phase_validation.py # Electrical phase utilities
278
341
  ├── protocol.py # PEP 544 protocols + PanelCapability flags
279
- ├── simulation.py # Simulation engine (YAML-driven, snapshot-producing)
280
342
  └── mqtt/
281
343
  ├── __init__.py
344
+ ├── accumulator.py # HomiePropertyAccumulator (Homie v5 protocol layer)
282
345
  ├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern)
283
346
  ├── client.py # SpanMqttClient (all three protocols)
284
347
  ├── connection.py # AsyncMqttBridge (event-loop-driven, no threads)
285
348
  ├── const.py # MQTT/Homie constants + UUID helpers
286
- ├── homie.py # HomieDeviceConsumer (Homie v5 state machine)
349
+ ├── homie.py # HomieDeviceConsumer (SPAN snapshot builder)
287
350
  └── models.py # MqttClientConfig, MqttTransport
288
351
  ```
289
352
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "2.4.2"
3
+ version = "2.5.0"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -45,7 +45,7 @@ requires = ["hatchling"]
45
45
  build-backend = "hatchling.build"
46
46
 
47
47
  [tool.hatch.build.targets.wheel]
48
- packages = ["src/span_panel_api"]
48
+ packages = ["src/span_panel_api", "scripts"]
49
49
 
50
50
  [tool.ruff]
51
51
  line-length = 125
@@ -37,7 +37,7 @@ from .models import (
37
37
  V2HomieSchema,
38
38
  V2StatusInfo,
39
39
  )
40
- from .mqtt import MqttClientConfig, SpanMqttClient
40
+ from .mqtt import HomieLifecycle, HomiePropertyAccumulator, MqttClientConfig, SpanMqttClient
41
41
  from .phase_validation import (
42
42
  PhaseDistribution,
43
43
  are_tabs_opposite_phase,
@@ -54,7 +54,7 @@ from .protocol import (
54
54
  StreamingCapableProtocol,
55
55
  )
56
56
 
57
- __version__ = "2.4.0"
57
+ __version__ = "2.5.0"
58
58
  # fmt: off
59
59
  __all__ = [ # noqa: RUF022
60
60
  # Protocols
@@ -90,6 +90,8 @@ __all__ = [ # noqa: RUF022
90
90
  "regenerate_passphrase",
91
91
  "register_v2",
92
92
  # Transport
93
+ "HomieLifecycle",
94
+ "HomiePropertyAccumulator",
93
95
  "MqttClientConfig",
94
96
  "SpanMqttClient",
95
97
  # Phase validation
@@ -42,6 +42,8 @@ class SpanCircuitSnapshot:
42
42
  relay_requester: str = "UNKNOWN" # v2 new: circuit/relay-requester
43
43
  energy_accum_update_time_s: int = 0 # v1: poll timestamp | v2: MQTT arrival time
44
44
  instant_power_update_time_s: int = 0 # v1: poll timestamp | v2: MQTT arrival time
45
+ relay_state_target: str | None = None # v2: $target for relay (desired state)
46
+ priority_target: str | None = None # v2: $target for shed-priority (desired state)
45
47
 
46
48
 
47
49
  @dataclass(frozen=True, slots=True)
@@ -1,5 +1,6 @@
1
1
  """SPAN Panel MQTT/Homie transport."""
2
2
 
3
+ from .accumulator import HomieLifecycle, HomiePropertyAccumulator
3
4
  from .async_client import AsyncMQTTClient
4
5
  from .client import SpanMqttClient
5
6
  from .connection import AsyncMqttBridge
@@ -10,6 +11,8 @@ __all__ = [
10
11
  "AsyncMQTTClient",
11
12
  "AsyncMqttBridge",
12
13
  "HomieDeviceConsumer",
14
+ "HomieLifecycle",
15
+ "HomiePropertyAccumulator",
13
16
  "MqttClientConfig",
14
17
  "SpanMqttClient",
15
18
  ]