span-panel-api 2.4.1__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 (83) hide show
  1. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.github/workflows/ci.yml +6 -7
  2. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.github/workflows/dependabot-auto-approve.yml +1 -1
  3. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.github/workflows/dependabot-auto-merge.yml +1 -1
  4. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.github/workflows/release.yml +2 -2
  5. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/CHANGELOG.md +23 -0
  6. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/PKG-INFO +80 -20
  7. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/README.md +79 -19
  8. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/pyproject.toml +2 -2
  9. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/__init__.py +4 -2
  10. span_panel_api-2.5.0/src/span_panel_api/_http.py +66 -0
  11. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/models.py +2 -0
  12. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/__init__.py +3 -0
  13. span_panel_api-2.5.0/src/span_panel_api/mqtt/accumulator.py +273 -0
  14. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/client.py +5 -1
  15. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/homie.py +181 -211
  16. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/conftest.py +9 -0
  17. span_panel_api-2.5.0/tests/test_accumulator.py +439 -0
  18. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_auth_and_homie_helpers.py +8 -3
  19. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_detection_auth.py +15 -3
  20. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_mqtt_homie.py +357 -274
  21. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/uv.lock +47 -47
  22. span_panel_api-2.4.1/src/span_panel_api/_http.py +0 -27
  23. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.codefactor +0 -0
  24. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.codefactor.yml +0 -0
  25. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.deps-installed +0 -0
  26. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  27. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  28. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.github/dependabot.yml +0 -0
  29. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.gitignore +0 -0
  30. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.markdownlint-cli2.jsonc +0 -0
  31. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.markdownlint.json +0 -0
  32. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.pre-commit-config.yaml +0 -0
  33. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.prettierrc.json +0 -0
  34. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.vscode/extensions.json +0 -0
  35. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.vscode/tasks.json +0 -0
  36. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/DEVELOPMENT.md +0 -0
  37. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/LICENSE +0 -0
  38. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/SECURITY.md +0 -0
  39. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/conftest.py +0 -0
  40. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/coverage_output.log +0 -0
  41. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/debug_unmapped.py +0 -0
  42. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/developer_attribute_readme.md +0 -0
  43. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/openapi.json +0 -0
  44. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/pytest.ini +0 -0
  45. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/pytest_output.log +0 -0
  46. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/scripts/__init__.py +0 -0
  47. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/scripts/coverage.py +0 -0
  48. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/scripts/format.sh +0 -0
  49. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/scripts/format_markdown.py +0 -0
  50. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/scripts/test_live_auth.py +0 -0
  51. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/setup-hooks.sh +0 -0
  52. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/auth.py +0 -0
  53. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/const.py +0 -0
  54. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/detection.py +0 -0
  55. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/exceptions.py +0 -0
  56. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/factory.py +0 -0
  57. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  58. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/connection.py +0 -0
  59. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/const.py +0 -0
  60. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/field_metadata.py +0 -0
  61. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/models.py +0 -0
  62. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/phase_validation.py +0 -0
  63. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/protocol.py +0 -0
  64. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/py.typed +0 -0
  65. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  66. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  67. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  68. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/fixtures/v2/README.md +0 -0
  69. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/fixtures/v2/homie_schema.json +0 -0
  70. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/fixtures/v2/status.json +0 -0
  71. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  72. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  73. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  74. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/simulation_fixtures/status.response.txt +0 -0
  75. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_async_mqtt_client.py +0 -0
  76. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_field_metadata.py +0 -0
  77. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_mqtt_bridge.py +0 -0
  78. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_mqtt_connect_flow.py +0 -0
  79. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_mqtt_debounce.py +0 -0
  80. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_phase_validation_configs.py +0 -0
  81. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_phase_validation_errors.py +0 -0
  82. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_protocol_conformance.py +0 -0
  83. {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_protocol_models.py +0 -0
@@ -11,7 +11,7 @@ jobs:
11
11
  runs-on: ubuntu-latest
12
12
  strategy:
13
13
  matrix:
14
- python-version: ["3.13", "3.14"]
14
+ python-version: ["3.14"]
15
15
 
16
16
  steps:
17
17
  - name: Checkout code
@@ -21,10 +21,9 @@ jobs:
21
21
  uses: actions/setup-python@v6
22
22
  with:
23
23
  python-version: ${{ matrix.python-version }}
24
- allow-prereleases: true
25
24
 
26
25
  - name: Install uv
27
- uses: astral-sh/setup-uv@v5
26
+ uses: astral-sh/setup-uv@v7
28
27
  with:
29
28
  enable-cache: true
30
29
 
@@ -50,10 +49,10 @@ jobs:
50
49
  - name: Set up Python
51
50
  uses: actions/setup-python@v6
52
51
  with:
53
- python-version: "3.13"
52
+ python-version: "3.14"
54
53
 
55
54
  - name: Install uv
56
- uses: astral-sh/setup-uv@v5
55
+ uses: astral-sh/setup-uv@v7
57
56
  with:
58
57
  enable-cache: true
59
58
 
@@ -79,10 +78,10 @@ jobs:
79
78
  - name: Set up Python
80
79
  uses: actions/setup-python@v6
81
80
  with:
82
- python-version: "3.13"
81
+ python-version: "3.14"
83
82
 
84
83
  - name: Install uv
85
- uses: astral-sh/setup-uv@v5
84
+ uses: astral-sh/setup-uv@v7
86
85
  with:
87
86
  enable-cache: true
88
87
 
@@ -15,7 +15,7 @@ jobs:
15
15
  steps:
16
16
  - name: Dependabot metadata
17
17
  id: metadata
18
- uses: dependabot/fetch-metadata@v2.5.0
18
+ uses: dependabot/fetch-metadata@v3.0.0
19
19
  with:
20
20
  github-token: "${{ secrets.GITHUB_TOKEN }}"
21
21
 
@@ -15,7 +15,7 @@ jobs:
15
15
  steps:
16
16
  - name: Dependabot metadata
17
17
  id: metadata
18
- uses: dependabot/fetch-metadata@v2.5.0
18
+ uses: dependabot/fetch-metadata@v3.0.0
19
19
  with:
20
20
  github-token: "${{ secrets.GITHUB_TOKEN }}"
21
21
 
@@ -18,10 +18,10 @@ jobs:
18
18
  - name: Set up Python
19
19
  uses: actions/setup-python@v6
20
20
  with:
21
- python-version: "3.13"
21
+ python-version: "3.14"
22
22
 
23
23
  - name: Install uv
24
- uses: astral-sh/setup-uv@v5
24
+ uses: astral-sh/setup-uv@v7
25
25
  with:
26
26
  enable-cache: true
27
27
 
@@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.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
+
23
+ ## [2.4.2] - 03/2026
24
+
25
+ ### Fixed
26
+
27
+ - **Moved SSL context creation to executor** — `httpx.AsyncClient()` eagerly calls `ssl.SSLContext.load_verify_locations()` with the system CA bundle, which is a blocking file I/O operation that triggers Home Assistant's event loop protection. The SSL
28
+ context is now created in an executor thread and passed to httpx via `verify=ctx`.
29
+
7
30
  ## [2.4.1] - 03/2026
8
31
 
9
32
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: span-panel-api
3
- Version: 2.4.1
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
@@ -22,7 +22,6 @@ Description-Content-Type: text/markdown
22
22
  [![CI Status](https://img.shields.io/github/actions/workflow/status/SpanPanel/span-panel-api/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/SpanPanel/span-panel-api/actions/workflows/ci.yml)
23
23
 
24
24
  [![Code Quality](https://img.shields.io/codefactor/grade/github/SpanPanel/span-panel-api?style=flat-square)](https://www.codefactor.io/repository/github/spanpanel/span-panel-api)
25
- [![Security](https://img.shields.io/snyk/vulnerabilities/github/SpanPanel/span-panel-api?style=flat-square)](https://snyk.io/test/github/SpanPanel/span-panel-api)
26
25
 
27
26
  [![Pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&style=flat-square)](https://github.com/pre-commit/pre-commit)
28
27
  [![Linting: Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json&style=flat-square)](https://github.com/astral-sh/ruff)
@@ -47,16 +46,18 @@ pip install span-panel-api
47
46
 
48
47
  - `httpx` — v2 authentication and detection endpoints
49
48
  - `paho-mqtt` — MQTT/Homie transport (real-time push)
50
- - `pyyaml` — simulation configuration
49
+ - `pyyaml` — YAML parsing for configuration and API payloads
51
50
 
52
51
  ## Architecture
53
52
 
54
- v2.0.0 is a ground-up rewrite. The package connects to the SPAN Panel's on-device MQTT broker using the [Homie v5](https://homieiot.github.io/) convention. All panel and circuit state is delivered via retained MQTT messages — no polling required.
55
-
56
53
  ### Transport
57
54
 
58
- 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
59
- 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.
60
61
 
61
62
  ### Event-Loop-Driven I/O (Home Assistant Compatible)
62
63
 
@@ -89,6 +90,7 @@ The library defines three structural subtyping protocols (PEP 544) that both the
89
90
  | -------------------------- | ------------------------------------------------------------------------------------- |
90
91
  | `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot` |
91
92
  | `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` |
93
+ | `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` |
92
94
  | `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` |
93
95
 
94
96
  Integration code programs against these protocols, not transport-specific classes.
@@ -100,7 +102,7 @@ All panel state is represented as immutable, frozen dataclasses:
100
102
  | Dataclass | Content |
101
103
  | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
102
104
  | `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, circuits, battery, PV, EVSE |
103
- | `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 |
104
106
  | `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh, vendor/product metadata, nameplate capacity |
105
107
  | `SpanPVSnapshot` | PV inverter: vendor/product metadata, nameplate capacity |
106
108
  | `SpanEvseSnapshot` | EVSE (EV charger): status, lock state, advertised current, vendor/product/serial/version metadata |
@@ -196,6 +198,40 @@ client = await create_span_client(
196
198
  )
197
199
  ```
198
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
+
199
235
  ### Circuit Control
200
236
 
201
237
  ```python
@@ -207,6 +243,18 @@ await client.set_circuit_relay("circuit-uuid", "CLOSED")
207
243
  await client.set_circuit_priority("circuit-uuid", "NEVER")
208
244
  ```
209
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
+
210
258
  ### API Version Detection
211
259
 
212
260
  Detect whether a panel supports v2 (unauthenticated probe):
@@ -226,7 +274,11 @@ if result.status_info:
226
274
  Standalone async functions for v2-specific HTTP operations:
227
275
 
228
276
  ```python
229
- 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
+ )
230
282
 
231
283
  # Register and obtain MQTT broker credentials
232
284
  auth = await register_v2("192.168.1.100", "my-app", passphrase="panel-passphrase")
@@ -243,21 +295,29 @@ print(f"Schema hash: {schema.types_schema_hash}")
243
295
 
244
296
  # Rotate MQTT broker password (invalidates previous password)
245
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)
246
307
  ```
247
308
 
248
309
  ## Error Handling
249
310
 
250
311
  All exceptions inherit from `SpanPanelError`:
251
312
 
252
- | Exception | Cause |
253
- | ------------------------------ | --------------------------------------------------------- |
254
- | `SpanPanelAuthError` | Invalid passphrase, expired token, or missing credentials |
255
- | `SpanPanelConnectionError` | Cannot reach the panel (network/DNS) |
256
- | `SpanPanelTimeoutError` | Request or connection timed out |
257
- | `SpanPanelValidationError` | Data validation failure |
258
- | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
259
- | `SpanPanelServerError` | Panel returned HTTP 500 |
260
- | `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 |
261
321
 
262
322
  ```python
263
323
  from span_panel_api import SpanPanelAuthError, SpanPanelConnectionError
@@ -294,14 +354,14 @@ src/span_panel_api/
294
354
  ├── models.py # Snapshot dataclasses (panel, circuit, battery, PV)
295
355
  ├── phase_validation.py # Electrical phase utilities
296
356
  ├── protocol.py # PEP 544 protocols + PanelCapability flags
297
- ├── simulation.py # Simulation engine (YAML-driven, snapshot-producing)
298
357
  └── mqtt/
299
358
  ├── __init__.py
359
+ ├── accumulator.py # HomiePropertyAccumulator (Homie v5 protocol layer)
300
360
  ├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern)
301
361
  ├── client.py # SpanMqttClient (all three protocols)
302
362
  ├── connection.py # AsyncMqttBridge (event-loop-driven, no threads)
303
363
  ├── const.py # MQTT/Homie constants + UUID helpers
304
- ├── homie.py # HomieDeviceConsumer (Homie v5 state machine)
364
+ ├── homie.py # HomieDeviceConsumer (SPAN snapshot builder)
305
365
  └── models.py # MqttClientConfig, MqttTransport
306
366
  ```
307
367
 
@@ -7,7 +7,6 @@
7
7
  [![CI Status](https://img.shields.io/github/actions/workflow/status/SpanPanel/span-panel-api/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/SpanPanel/span-panel-api/actions/workflows/ci.yml)
8
8
 
9
9
  [![Code Quality](https://img.shields.io/codefactor/grade/github/SpanPanel/span-panel-api?style=flat-square)](https://www.codefactor.io/repository/github/spanpanel/span-panel-api)
10
- [![Security](https://img.shields.io/snyk/vulnerabilities/github/SpanPanel/span-panel-api?style=flat-square)](https://snyk.io/test/github/SpanPanel/span-panel-api)
11
10
 
12
11
  [![Pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&style=flat-square)](https://github.com/pre-commit/pre-commit)
13
12
  [![Linting: Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json&style=flat-square)](https://github.com/astral-sh/ruff)
@@ -32,16 +31,18 @@ pip install span-panel-api
32
31
 
33
32
  - `httpx` — v2 authentication and detection endpoints
34
33
  - `paho-mqtt` — MQTT/Homie transport (real-time push)
35
- - `pyyaml` — simulation configuration
34
+ - `pyyaml` — YAML parsing for configuration and API payloads
36
35
 
37
36
  ## Architecture
38
37
 
39
- v2.0.0 is a ground-up rewrite. The package connects to the SPAN Panel's on-device MQTT broker using the [Homie v5](https://homieiot.github.io/) convention. All panel and circuit state is delivered via retained MQTT messages — no polling required.
40
-
41
38
  ### Transport
42
39
 
43
- 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
44
- 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.
45
46
 
46
47
  ### Event-Loop-Driven I/O (Home Assistant Compatible)
47
48
 
@@ -74,6 +75,7 @@ The library defines three structural subtyping protocols (PEP 544) that both the
74
75
  | -------------------------- | ------------------------------------------------------------------------------------- |
75
76
  | `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot` |
76
77
  | `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` |
78
+ | `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` |
77
79
  | `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` |
78
80
 
79
81
  Integration code programs against these protocols, not transport-specific classes.
@@ -85,7 +87,7 @@ All panel state is represented as immutable, frozen dataclasses:
85
87
  | Dataclass | Content |
86
88
  | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
87
89
  | `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, circuits, battery, PV, EVSE |
88
- | `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 |
89
91
  | `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh, vendor/product metadata, nameplate capacity |
90
92
  | `SpanPVSnapshot` | PV inverter: vendor/product metadata, nameplate capacity |
91
93
  | `SpanEvseSnapshot` | EVSE (EV charger): status, lock state, advertised current, vendor/product/serial/version metadata |
@@ -181,6 +183,40 @@ client = await create_span_client(
181
183
  )
182
184
  ```
183
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
+
184
220
  ### Circuit Control
185
221
 
186
222
  ```python
@@ -192,6 +228,18 @@ await client.set_circuit_relay("circuit-uuid", "CLOSED")
192
228
  await client.set_circuit_priority("circuit-uuid", "NEVER")
193
229
  ```
194
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
+
195
243
  ### API Version Detection
196
244
 
197
245
  Detect whether a panel supports v2 (unauthenticated probe):
@@ -211,7 +259,11 @@ if result.status_info:
211
259
  Standalone async functions for v2-specific HTTP operations:
212
260
 
213
261
  ```python
214
- 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
+ )
215
267
 
216
268
  # Register and obtain MQTT broker credentials
217
269
  auth = await register_v2("192.168.1.100", "my-app", passphrase="panel-passphrase")
@@ -228,21 +280,29 @@ print(f"Schema hash: {schema.types_schema_hash}")
228
280
 
229
281
  # Rotate MQTT broker password (invalidates previous password)
230
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)
231
292
  ```
232
293
 
233
294
  ## Error Handling
234
295
 
235
296
  All exceptions inherit from `SpanPanelError`:
236
297
 
237
- | Exception | Cause |
238
- | ------------------------------ | --------------------------------------------------------- |
239
- | `SpanPanelAuthError` | Invalid passphrase, expired token, or missing credentials |
240
- | `SpanPanelConnectionError` | Cannot reach the panel (network/DNS) |
241
- | `SpanPanelTimeoutError` | Request or connection timed out |
242
- | `SpanPanelValidationError` | Data validation failure |
243
- | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
244
- | `SpanPanelServerError` | Panel returned HTTP 500 |
245
- | `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 |
246
306
 
247
307
  ```python
248
308
  from span_panel_api import SpanPanelAuthError, SpanPanelConnectionError
@@ -279,14 +339,14 @@ src/span_panel_api/
279
339
  ├── models.py # Snapshot dataclasses (panel, circuit, battery, PV)
280
340
  ├── phase_validation.py # Electrical phase utilities
281
341
  ├── protocol.py # PEP 544 protocols + PanelCapability flags
282
- ├── simulation.py # Simulation engine (YAML-driven, snapshot-producing)
283
342
  └── mqtt/
284
343
  ├── __init__.py
344
+ ├── accumulator.py # HomiePropertyAccumulator (Homie v5 protocol layer)
285
345
  ├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern)
286
346
  ├── client.py # SpanMqttClient (all three protocols)
287
347
  ├── connection.py # AsyncMqttBridge (event-loop-driven, no threads)
288
348
  ├── const.py # MQTT/Homie constants + UUID helpers
289
- ├── homie.py # HomieDeviceConsumer (Homie v5 state machine)
349
+ ├── homie.py # HomieDeviceConsumer (SPAN snapshot builder)
290
350
  └── models.py # MqttClientConfig, MqttTransport
291
351
  ```
292
352
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "2.4.1"
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
@@ -0,0 +1,66 @@
1
+ """Shared HTTP helpers for SPAN Panel bootstrap REST calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncIterator
7
+ from contextlib import asynccontextmanager
8
+ from dataclasses import dataclass, field
9
+ import ssl
10
+
11
+ import httpx
12
+
13
+
14
+ @dataclass
15
+ class _SSLCache:
16
+ """Mutable container for the cached SSLContext and its async lock."""
17
+
18
+ context: ssl.SSLContext | None = None
19
+ lock: asyncio.Lock | None = field(default=None, repr=False)
20
+
21
+ def get_lock(self) -> asyncio.Lock:
22
+ """Return the async lock, creating it lazily."""
23
+ if self.lock is None:
24
+ self.lock = asyncio.Lock()
25
+ return self.lock
26
+
27
+
28
+ _ssl_cache = _SSLCache()
29
+
30
+
31
+ def _build_url(host: str, port: int, path: str) -> str:
32
+ """Build an HTTP URL, omitting the port when it is the default (80)."""
33
+ if port == 80:
34
+ return f"http://{host}{path}"
35
+ return f"http://{host}:{port}{path}"
36
+
37
+
38
+ async def _create_ssl_context() -> ssl.SSLContext:
39
+ """Return a cached default SSL context, creating it in an executor on first call.
40
+
41
+ ``ssl.create_default_context()`` calls ``load_verify_locations`` which
42
+ performs blocking file I/O on the system CA bundle. The resulting context
43
+ is thread-safe and reusable, so we cache it for the lifetime of the process.
44
+ """
45
+ if _ssl_cache.context is not None:
46
+ return _ssl_cache.context
47
+ async with _ssl_cache.get_lock():
48
+ # Double-check after acquiring the lock.
49
+ if _ssl_cache.context is not None:
50
+ return _ssl_cache.context
51
+ loop = asyncio.get_running_loop()
52
+ _ssl_cache.context = await loop.run_in_executor(None, ssl.create_default_context)
53
+ return _ssl_cache.context
54
+
55
+
56
+ @asynccontextmanager
57
+ async def _get_client(
58
+ httpx_client: httpx.AsyncClient | None,
59
+ timeout: float,
60
+ ) -> AsyncIterator[httpx.AsyncClient]:
61
+ if httpx_client is not None:
62
+ yield httpx_client
63
+ return
64
+ ctx = await _create_ssl_context()
65
+ async with httpx.AsyncClient(timeout=timeout, verify=ctx) as client:
66
+ yield client
@@ -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
  ]