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.
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.github/workflows/ci.yml +6 -7
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.github/workflows/dependabot-auto-approve.yml +1 -1
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.github/workflows/dependabot-auto-merge.yml +1 -1
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.github/workflows/release.yml +2 -2
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/CHANGELOG.md +23 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/PKG-INFO +80 -20
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/README.md +79 -19
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/pyproject.toml +2 -2
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/__init__.py +4 -2
- span_panel_api-2.5.0/src/span_panel_api/_http.py +66 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/models.py +2 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/__init__.py +3 -0
- span_panel_api-2.5.0/src/span_panel_api/mqtt/accumulator.py +273 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/client.py +5 -1
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/homie.py +181 -211
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/conftest.py +9 -0
- span_panel_api-2.5.0/tests/test_accumulator.py +439 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_auth_and_homie_helpers.py +8 -3
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_detection_auth.py +15 -3
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_mqtt_homie.py +357 -274
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/uv.lock +47 -47
- span_panel_api-2.4.1/src/span_panel_api/_http.py +0 -27
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.codefactor +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.codefactor.yml +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.deps-installed +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.github/dependabot.yml +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.gitignore +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.markdownlint-cli2.jsonc +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.markdownlint.json +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.pre-commit-config.yaml +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.prettierrc.json +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.vscode/extensions.json +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/.vscode/tasks.json +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/DEVELOPMENT.md +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/LICENSE +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/SECURITY.md +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/conftest.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/coverage_output.log +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/debug_unmapped.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/developer_attribute_readme.md +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/openapi.json +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/pytest.ini +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/pytest_output.log +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/scripts/__init__.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/scripts/coverage.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/scripts/format.sh +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/scripts/format_markdown.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/scripts/test_live_auth.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/setup-hooks.sh +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/auth.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/field_metadata.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/fixtures/v2/homie_schema.json +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-2.4.1 → span_panel_api-2.5.0}/tests/test_protocol_conformance.py +0 -0
- {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.
|
|
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@
|
|
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.
|
|
52
|
+
python-version: "3.14"
|
|
54
53
|
|
|
55
54
|
- name: Install uv
|
|
56
|
-
uses: astral-sh/setup-uv@
|
|
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.
|
|
81
|
+
python-version: "3.14"
|
|
83
82
|
|
|
84
83
|
- name: Install uv
|
|
85
|
-
uses: astral-sh/setup-uv@
|
|
84
|
+
uses: astral-sh/setup-uv@v7
|
|
86
85
|
with:
|
|
87
86
|
enable-cache: true
|
|
88
87
|
|
|
@@ -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.
|
|
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
|
[](https://github.com/SpanPanel/span-panel-api/actions/workflows/ci.yml)
|
|
23
23
|
|
|
24
24
|
[](https://www.codefactor.io/repository/github/spanpanel/span-panel-api)
|
|
25
|
-
[](https://snyk.io/test/github/SpanPanel/span-panel-api)
|
|
26
25
|
|
|
27
26
|
[](https://github.com/pre-commit/pre-commit)
|
|
28
27
|
[](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` —
|
|
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
|
|
59
|
-
|
|
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
|
|
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
|
|
253
|
-
|
|
|
254
|
-
| `SpanPanelAuthError`
|
|
255
|
-
| `SpanPanelConnectionError`
|
|
256
|
-
| `SpanPanelTimeoutError`
|
|
257
|
-
| `SpanPanelValidationError`
|
|
258
|
-
| `SpanPanelAPIError`
|
|
259
|
-
| `SpanPanelServerError`
|
|
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 (
|
|
364
|
+
├── homie.py # HomieDeviceConsumer (SPAN snapshot builder)
|
|
305
365
|
└── models.py # MqttClientConfig, MqttTransport
|
|
306
366
|
```
|
|
307
367
|
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
[](https://github.com/SpanPanel/span-panel-api/actions/workflows/ci.yml)
|
|
8
8
|
|
|
9
9
|
[](https://www.codefactor.io/repository/github/spanpanel/span-panel-api)
|
|
10
|
-
[](https://snyk.io/test/github/SpanPanel/span-panel-api)
|
|
11
10
|
|
|
12
11
|
[](https://github.com/pre-commit/pre-commit)
|
|
13
12
|
[](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` —
|
|
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
|
|
44
|
-
|
|
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
|
|
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
|
|
238
|
-
|
|
|
239
|
-
| `SpanPanelAuthError`
|
|
240
|
-
| `SpanPanelConnectionError`
|
|
241
|
-
| `SpanPanelTimeoutError`
|
|
242
|
-
| `SpanPanelValidationError`
|
|
243
|
-
| `SpanPanelAPIError`
|
|
244
|
-
| `SpanPanelServerError`
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
]
|