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.
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/CHANGELOG.md +16 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/PKG-INFO +80 -17
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/README.md +79 -16
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/pyproject.toml +2 -2
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/__init__.py +4 -2
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/models.py +2 -0
- {span_panel_api-2.4.2 → 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.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/client.py +5 -1
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/homie.py +181 -211
- span_panel_api-2.5.0/tests/test_accumulator.py +439 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_auth_and_homie_helpers.py +3 -1
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_mqtt_homie.py +357 -274
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/uv.lock +1 -1
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.codefactor +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.codefactor.yml +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.deps-installed +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.github/dependabot.yml +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.github/workflows/ci.yml +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.github/workflows/dependabot-auto-approve.yml +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.github/workflows/dependabot-auto-merge.yml +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.github/workflows/release.yml +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.gitignore +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.markdownlint-cli2.jsonc +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.markdownlint.json +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.pre-commit-config.yaml +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.prettierrc.json +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.vscode/extensions.json +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/.vscode/tasks.json +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/DEVELOPMENT.md +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/LICENSE +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/SECURITY.md +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/conftest.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/coverage_output.log +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/debug_unmapped.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/developer_attribute_readme.md +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/openapi.json +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/pytest.ini +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/pytest_output.log +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/scripts/__init__.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/scripts/coverage.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/scripts/format.sh +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/scripts/format_markdown.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/scripts/test_live_auth.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/setup-hooks.sh +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/auth.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/field_metadata.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/conftest.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/fixtures/v2/homie_schema.json +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_detection_auth.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-2.4.2 → span_panel_api-2.5.0}/tests/test_protocol_conformance.py +0 -0
- {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.
|
|
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` —
|
|
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
|
|
56
|
-
|
|
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
|
|
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
|
|
250
|
-
|
|
|
251
|
-
| `SpanPanelAuthError`
|
|
252
|
-
| `SpanPanelConnectionError`
|
|
253
|
-
| `SpanPanelTimeoutError`
|
|
254
|
-
| `SpanPanelValidationError`
|
|
255
|
-
| `SpanPanelAPIError`
|
|
256
|
-
| `SpanPanelServerError`
|
|
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 (
|
|
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` —
|
|
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
|
|
41
|
-
|
|
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
|
|
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
|
|
235
|
-
|
|
|
236
|
-
| `SpanPanelAuthError`
|
|
237
|
-
| `SpanPanelConnectionError`
|
|
238
|
-
| `SpanPanelTimeoutError`
|
|
239
|
-
| `SpanPanelValidationError`
|
|
240
|
-
| `SpanPanelAPIError`
|
|
241
|
-
| `SpanPanelServerError`
|
|
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 (
|
|
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.
|
|
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
|
|
@@ -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
|
]
|