A client library for SPAN Panel API
Project description
SPAN Panel API
A Python client library for the SPAN Panel v2 API, using MQTT/Homie for real-time push-based panel state.
v1.x Sunset Notice
Package versions prior to 2.0.0 are deprecated. These versions depend on the SPAN v1 REST API, which will be retired when SPAN sunsets v1 firmware at the end of 2026. Users should upgrade to v2.0.0 or later, which requires v2 firmware
(spanos2/r202603/05 or later) and a panel passphrase.
Installation
pip install span-panel-api
Dependencies
httpx— v2 authentication and detection endpointspaho-mqtt— MQTT/Homie transport (real-time push)pyyaml— YAML parsing for configuration and API payloads
Architecture
Transport
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:
HomiePropertyAccumulator— handles message routing, property and$targetstorage, dirty-node tracking, and an explicit lifecycle state machine (HomieLifecycle). Protocol-only; no SPAN domain knowledge.HomieDeviceConsumer— reads from the accumulator via a query API and builds typedSpanPanelSnapshotdataclasses. Handles power sign normalization, DSM derivation, unmapped tab synthesis, and dirty-node-aware snapshot caching.
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.
Event-Loop-Driven I/O (Home Assistant Compatible)
The MQTT transport is designed around the Home Assistant core async pattern — all paho-mqtt I/O runs on the asyncio event loop with no background threads:
- NullLock replacement — paho-mqtt's seven internal threading locks are replaced with no-op
NullLockinstances at setup time, eliminating lock contention since all access is single-threaded on the event loop. add_reader/add_writer—AsyncMqttBridgeregisters the MQTT socket with the event loop vialoop.add_reader()andloop.add_writer(), calling paho'sloop_read()/loop_write()directly from I/O callbacks rather than from aloop_start()background thread.- Periodic misc — A
loop.call_at()timer fires every second to callloop_misc()for keepalive and timeout housekeeping. - Executor bridge for connect — The initial TLS handshake and TCP connect are blocking operations, so they run in
loop.run_in_executor(). Once the executor returns, socket callbacks are immediately switched from sync bridges (call_soon_threadsafe) back to the async-only versions.
This means the library can be dropped into any asyncio application — including Home Assistant — without spawning threads or requiring thread-safe wrappers.
Circuit Name Synchronization
Circuit names arrive as MQTT retained messages that may land after the Homie device transitions to $state=ready. The client handles this with a bounded wait during connect():
- After the device reaches ready state, the client polls
HomieDeviceConsumer.circuit_nodes_missing_names()every 250ms. - As retained name properties arrive, the consumer stores them. Once all circuit-type nodes have a name, the wait returns immediately.
- If names have not all arrived within 10 seconds, the timeout expires (non-fatal) and the client proceeds — circuits without names will use fallback identifiers.
This ensures that the first get_snapshot() after connect returns human-readable circuit names in the common case, while never blocking indefinitely on a missing retained message.
Protocols
The library defines three structural subtyping protocols (PEP 544) that both the MQTT transport and the simulation engine implement:
| Protocol | Purpose |
|---|---|
SpanPanelClientProtocol |
Core lifecycle: connect, close, ping, get_snapshot |
CircuitControlProtocol |
Relay and shed-priority control: set_circuit_relay, set_circuit_priority |
PanelControlProtocol |
Panel-level control: set_dominant_power_source |
StreamingCapableProtocol |
Push-based updates: register_snapshot_callback, start_streaming, stop_streaming |
Integration code programs against these protocols, not transport-specific classes.
Snapshots
All panel state is represented as immutable, frozen dataclasses:
| Dataclass | Content |
|---|---|
SpanPanelSnapshot |
Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, circuits, battery, PV, EVSE |
SpanCircuitSnapshot |
Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current, $target pending state |
SpanBatterySnapshot |
BESS: SoC percentage, SoE kWh, vendor/product metadata, nameplate capacity |
SpanPVSnapshot |
PV inverter: vendor/product metadata, nameplate capacity |
SpanEvseSnapshot |
EVSE (EV charger): status, lock state, advertised current, vendor/product/serial/version metadata |
Usage
Factory Pattern (Recommended)
The create_span_client() factory handles v2 registration and returns a configured SpanMqttClient:
import asyncio
from span_panel_api import create_span_client
async def main():
client = await create_span_client(
host="192.168.1.100",
passphrase="your-panel-passphrase",
)
try:
await client.connect()
# Get a point-in-time snapshot
snapshot = await client.get_snapshot()
print(f"Grid power: {snapshot.instant_grid_power_w}W")
print(f"Firmware: {snapshot.firmware_version}")
print(f"Circuits: {len(snapshot.circuits)}")
for cid, circuit in snapshot.circuits.items():
print(f" {circuit.name}: {circuit.instant_power_w}W ({circuit.relay_state})")
finally:
await client.close()
asyncio.run(main())
Streaming Pattern
For real-time push updates without polling:
import asyncio
from span_panel_api import create_span_client, SpanPanelSnapshot
async def on_snapshot(snapshot: SpanPanelSnapshot) -> None:
print(f"Grid: {snapshot.instant_grid_power_w}W, Circuits: {len(snapshot.circuits)}")
async def main():
client = await create_span_client(
host="192.168.1.100",
passphrase="your-panel-passphrase",
)
try:
await client.connect()
# Register callback and start streaming
unsubscribe = client.register_snapshot_callback(on_snapshot)
await client.start_streaming()
# Run until interrupted
await asyncio.Event().wait()
finally:
await client.stop_streaming()
await client.close()
asyncio.run(main())
Pre-Built Config Pattern
If you already have MQTT broker credentials (e.g., stored from a previous registration):
from span_panel_api import create_span_client, MqttClientConfig
config = MqttClientConfig(
broker_host="192.168.1.100",
username="stored-username",
password="stored-password",
mqtts_port=8883,
ws_port=9001,
wss_port=443,
)
client = await create_span_client(
host="192.168.1.100",
mqtt_config=config,
serial_number="nj-2316-XXXX",
)
Direct Client Construction
Consumers that manage their own registration and broker configuration can instantiate SpanMqttClient directly:
from span_panel_api import SpanMqttClient, MqttClientConfig
config = MqttClientConfig(
broker_host="192.168.1.100",
username="stored-username",
password="stored-password",
mqtts_port=8883,
ws_port=9001,
wss_port=443,
)
client = SpanMqttClient(
host="192.168.1.100",
serial_number="nj-2316-XXXX",
broker_config=config,
snapshot_interval=1.0,
)
await client.connect()
Scan Frequency
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.
# Reduce snapshot frequency to every 2 seconds
client.set_snapshot_interval(2.0)
Circuit Control
# Set circuit relay (OPEN/CLOSED)
await client.set_circuit_relay("circuit-uuid", "OPEN")
await client.set_circuit_relay("circuit-uuid", "CLOSED")
# Set circuit shed priority (NEVER / SOC_THRESHOLD / OFF_GRID)
await client.set_circuit_priority("circuit-uuid", "NEVER")
Pending-State Detection
When the panel publishes Homie $target properties, SpanCircuitSnapshot exposes the desired state alongside the actual state:
for cid, circuit in snapshot.circuits.items():
if circuit.relay_state_target and circuit.relay_state_target != circuit.relay_state:
print(f" {circuit.name}: relay transitioning {circuit.relay_state} → {circuit.relay_state_target}")
if circuit.priority_target and circuit.priority_target != circuit.priority:
print(f" {circuit.name}: priority pending {circuit.priority} → {circuit.priority_target}")
API Version Detection
Detect whether a panel supports v2 (unauthenticated probe):
from span_panel_api import detect_api_version
result = await detect_api_version("192.168.1.100")
print(f"API version: {result.api_version}") # "v1" or "v2"
if result.status_info:
print(f"Serial: {result.status_info.serial_number}")
print(f"Firmware: {result.status_info.firmware_version}")
v2 Authentication Functions
Standalone async functions for v2-specific HTTP operations:
from span_panel_api import (
register_v2, download_ca_cert, get_homie_schema,
regenerate_passphrase, get_v2_status,
register_fqdn, get_fqdn, delete_fqdn,
)
# Register and obtain MQTT broker credentials
auth = await register_v2("192.168.1.100", "my-app", passphrase="panel-passphrase")
print(f"Broker: {auth.ebus_broker_host}:{auth.ebus_broker_mqtts_port}")
print(f"Serial: {auth.serial_number}")
# Download the panel's CA certificate (for TLS verification)
pem = await download_ca_cert("192.168.1.100")
# Fetch the Homie property schema (unauthenticated)
schema = await get_homie_schema("192.168.1.100")
print(f"Panel size: {schema.panel_size} spaces")
print(f"Schema hash: {schema.types_schema_hash}")
# Rotate MQTT broker password (invalidates previous password)
new_password = await regenerate_passphrase("192.168.1.100", token=auth.access_token)
# Get panel status (unauthenticated)
status = await get_v2_status("192.168.1.100")
print(f"Serial: {status.serial_number}, Firmware: {status.firmware_version}")
# FQDN management (for panel TLS certificate SAN)
await register_fqdn("192.168.1.100", "panel.local", token=auth.access_token)
fqdn = await get_fqdn("192.168.1.100", token=auth.access_token)
await delete_fqdn("192.168.1.100", token=auth.access_token)
Error Handling
All exceptions inherit from SpanPanelError:
| Exception | Cause |
|---|---|
SpanPanelAuthError |
Invalid passphrase, expired token, or missing credentials |
SpanPanelConnectionError |
Cannot reach the panel (network/DNS) |
SpanPanelTimeoutError |
Request or connection timed out |
SpanPanelValidationError |
Data validation failure |
SpanPanelAPIError |
Unexpected HTTP response from v2 endpoints |
SpanPanelServerError |
Panel returned HTTP 500 |
from span_panel_api import SpanPanelAuthError, SpanPanelConnectionError
try:
client = await create_span_client(host="192.168.1.100", passphrase="wrong")
except SpanPanelAuthError:
print("Invalid passphrase")
except SpanPanelConnectionError:
print("Cannot reach panel")
Capabilities
The PanelCapability flag enum advertises transport features at runtime:
| Flag | Meaning |
|---|---|
EBUS_MQTT |
Connected via MQTT/Homie transport |
PUSH_STREAMING |
Supports real-time push callbacks |
CIRCUIT_CONTROL |
Can set relay state and shed priority |
BATTERY_SOE |
Battery state-of-energy available |
Project Structure
src/span_panel_api/
├── __init__.py # Public API exports
├── auth.py # v2 HTTP provisioning (register, cert, schema, passphrase)
├── const.py # Panel state constants (DSM, relay)
├── detection.py # detect_api_version() → DetectionResult
├── exceptions.py # Exception hierarchy
├── factory.py # create_span_client() → SpanMqttClient
├── models.py # Snapshot dataclasses (panel, circuit, battery, PV)
├── phase_validation.py # Electrical phase utilities
├── protocol.py # PEP 544 protocols + PanelCapability flags
└── mqtt/
├── __init__.py
├── accumulator.py # HomiePropertyAccumulator (Homie v5 protocol layer)
├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern)
├── client.py # SpanMqttClient (all three protocols)
├── connection.py # AsyncMqttBridge (event-loop-driven, no threads)
├── const.py # MQTT/Homie constants + UUID helpers
├── homie.py # HomieDeviceConsumer (SPAN snapshot builder)
└── models.py # MqttClientConfig, MqttTransport
Development
See DEVELOPMENT.md for setup, testing, and contribution guidelines.
License
MIT License - see LICENSE file for details.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file span_panel_api-2.5.1.tar.gz.
File metadata
- Download URL: span_panel_api-2.5.1.tar.gz
- Upload date:
- Size: 182.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ba3079f38ead0c0bf6f6f90640eb2e3a1b1afb2bda6f9a013119f883e8fcdac5
|
|
| MD5 |
417128fdce094b284527efa9e955020f
|
|
| BLAKE2b-256 |
092822447755fd23d1a4530e60e468e83cc4a4ff6203ccfcb49a4d9c00aea1e2
|
Provenance
The following attestation bundles were made for span_panel_api-2.5.1.tar.gz:
Publisher:
release.yml on SpanPanel/span-panel-api
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
span_panel_api-2.5.1.tar.gz -
Subject digest:
ba3079f38ead0c0bf6f6f90640eb2e3a1b1afb2bda6f9a013119f883e8fcdac5 - Sigstore transparency entry: 1207021305
- Sigstore integration time:
-
Permalink:
SpanPanel/span-panel-api@925a0d2176bb42aec56835c4513caeef255b1b0a -
Branch / Tag:
refs/tags/v2.5.1 - Owner: https://github.com/SpanPanel
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@925a0d2176bb42aec56835c4513caeef255b1b0a -
Trigger Event:
release
-
Statement type:
File details
Details for the file span_panel_api-2.5.1-py3-none-any.whl.
File metadata
- Download URL: span_panel_api-2.5.1-py3-none-any.whl
- Upload date:
- Size: 54.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
857b999a20a39f336fbe305f6bad3edfc15cec3685a8ac89a46287f396836752
|
|
| MD5 |
26fed18532275722346fc88b5305e3e2
|
|
| BLAKE2b-256 |
c11e59c38c1a7344b302b9d87d7b88cea04351a219b12f4ff750d2fd4847866a
|
Provenance
The following attestation bundles were made for span_panel_api-2.5.1-py3-none-any.whl:
Publisher:
release.yml on SpanPanel/span-panel-api
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
span_panel_api-2.5.1-py3-none-any.whl -
Subject digest:
857b999a20a39f336fbe305f6bad3edfc15cec3685a8ac89a46287f396836752 - Sigstore transparency entry: 1207021350
- Sigstore integration time:
-
Permalink:
SpanPanel/span-panel-api@925a0d2176bb42aec56835c4513caeef255b1b0a -
Branch / Tag:
refs/tags/v2.5.1 - Owner: https://github.com/SpanPanel
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@925a0d2176bb42aec56835c4513caeef255b1b0a -
Trigger Event:
release
-
Statement type: