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
Two packages: the transport, and a parser for your panel's schema. span-panel-api contains no parser — installing it alone gives a client that connects and then raises SpanPanelAdapterMissingError.
# flat schema, firmware r202603-r202627
pip install "span-panel-api[schema-0]"
# parent/child schema, firmware r202633+ (data-model-version 1.x)
pip install "span-panel-api[schema-1]"
# support either panel from one install
pip install "span-panel-api[schema-0,schema-1]"
The extras are the recommended spelling because they give pip install -U a correct upgrade path; naming span-panel-api-schema-0 / span-panel-api-schema-1 directly works too.
The parser is hot-loaded, not imported
span-panel-api never imports a parser. Each wire format is its own distribution, registering itself under the span_panel_api.schema_adapters entry-point group, and the transport reaches it by key at runtime:
- Ask the panel first. Before the broker is opened, the client fetches
GET /api/v2/homie/schemaover REST and readsdataModelVersion. Absence means the flat schema — a real signal, since the property arrived with the firmware that introduced parent/child. A value whose major can be read but whose form is non-canonical (1,1.0-beta) dispatches on that major and logs the deviation; one with no extractable major raisesSpanPanelSchemaVersionErrorrather than guessing. - Enumerate without importing.
installed_adapter_keys()reads distribution metadata only. Nothing is imported to find out what is installed, so a flat panel never pays forspan-panel-api-schema-1— nor for the eBus SDK underneath it. - Resolve on demand, once. The adapter for the selected key is imported the first time a panel asks for it, then cached. The async paths run enumeration and resolution in a thread, so neither blocks the event loop.
- Verify the contract before trusting it. Every adapter declares
ADAPTER_CONTRACTas a literal, and discovery rejects any that does not match this package'sADAPTER_CONTRACT_VERSION. Member presence is not the whole contract — a Protocol cannot express signatures at runtime — so this is what stops two packages built against different versions of each other failing much later as a bareTypeErrorinside the transport. A rejection is logged rather than raised, so one unusable third-party adapter cannot take down a panel whose own adapter is fine. - Re-dispatch when the panel changes underneath you. A panel that upgrades firmware from flat to parent/child mid-life drops MQTT, reboots and comes back on a new schema. The client refetches, resolves the new adapter before touching any state, and swaps the parser in place — no reload. An install with no adapter for the new generation logs which package to install and keeps the parser it has.
Three errors keep the failure modes apart, because the remedy differs: SpanPanelAdapterMissingError (install something), SpanPanelSchemaVersionError (a schema no adapter can even be named for), and SpanPanelAdapterIncompatibleError (installing more
cannot help). All are exported from the top-level package.
The consequence worth planning around: supporting a new panel schema is an install, not an upgrade. The distributions version independently — see RELEASE.md.
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. It owns the connection, the subscription and the dispatch decision — and nothing else. Everything that knows what a topic means lives
in the adapter for that panel's schema:
- The transport (this package) makes one wildcard subscription, routes messages, tracks connection state, publishes commands, and hands raw messages to whichever parser was resolved for this panel.
- The parser (
span-panel-api-schema-0orspan-panel-api-schema-1) accumulates properties, decides when the panel is ready to read, and builds typedSpanPanelSnapshotdataclasses from what it has.
That boundary is why HomiePropertyAccumulator, HomieLifecycle and HomieDeviceConsumer are not exported from this package: all three are flat-schema-specific rather than Homie-convention-level. The accumulator filters every topic against a single
device's prefix and stores node → prop, which drops nearly every message under the parent/child model, and HomieLifecycle's members are not Homie 5 $state values but a consumer-side progression encoding "one description received ⇒ ready". They live
in span_panel_api_schema_0, where that model is correct. The parent/child parser reaches the same result differently, replaying the retained tree through the eBus SDK and waiting for every declared device to describe itself at any depth.
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 the resolved adapter's
circuit_nodes_missing_names()every 250ms — aSchemaAdaptermember, so both parsers answer it in their own terms. - 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 structural subtyping protocols (PEP 544). All are runtime_checkable, so a consumer asks isinstance before offering a control rather than assuming the panel in front of it supports one:
| Protocol | Purpose |
|---|---|
SpanPanelClientProtocol |
Core lifecycle: connect, close, ping, get_snapshot, register_connection_callback |
CircuitControlProtocol |
Relay and shed-priority control: set_circuit_relay, set_circuit_priority |
PanelControlProtocol |
Panel-level control: set_dominant_power_source |
EvseControlProtocol |
Per-charger control: set_evse_charge_limit(node_id, amps) |
AdoptedControlProtocol |
Write to a settable property of a device this library models nothing for |
StreamingCapableProtocol |
Push-based updates: register_snapshot_callback, start_streaming, stop_streaming |
The first five differ in subject, not just in name. EvseControlProtocol is separate from PanelControlProtocol because several chargers may be commissioned at once and every call names which one. AdoptedControlProtocol differs in kind: the curated
setters name a control this library understands and translate or bound the value on the way out, while this one names a property by its wire address and passes the value through, because the declaration is all anybody here knows about it. That write is
authorised by the snapshot rather than by its arguments — the transport resolves the property against the current adopted_devices and refuses anything it does not find carrying a set topic, so a device this library does model cannot be addressed
through it.
A seventh protocol, SchemaAdapter, is the bootstrap-to-parser contract rather than a consumer-facing one; it is what an adapter distribution implements and what discovery checks. Integration code programs against the protocols above, not against
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, shed forecast, circuits, battery, PV, EVSE, MID |
SpanCircuitSnapshot |
Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current, $target pending state |
SpanBatterySnapshot |
BESS: SoC percentage, SoE kWh, own meter reading, communication state, link health, model / part_number, nameplate capacity |
SpanPVSnapshot |
PV inverter: link health, model / part_number, nameplate capacity |
SpanEvseSnapshot |
EVSE (EV charger): status, lock state, advertised current, link health, model / part_number / serial / version metadata |
SpanMidSnapshot |
Microgrid Interconnect Device: islanding state, grid state, grid-forming entity |
AdoptedDevice |
A device type this library models nothing for, carried whole: identity, readings, proxy link |
ExtensionProperty |
A vendor property on a device this library does model, with its value and the subject it hangs off |
Identity is normalised across every DER class: model is the human designation and part_number is the SKU, on battery, evse and pv alike. product_name was retired in 3.0.0 — see the changelog, because battery.model changes value for
existing flat users at that upgrade.
mid, adopted_devices, extension_properties and the per-DER link-health fields exist only under the parent/child schema. They are None or empty on a flat panel rather than absent, so a consumer reads the same snapshot type either way.
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()
# The upstream lugs' own meter. That is grid flow only where the lugs are
# the utility connection point; a BESS wired ahead of them, or a panel fed
# by another panel, makes it this panel's feed instead. `power_flow_grid`
# is the site-level figure in every topology.
if snapshot.lugs_at_service_entrance:
print(f"Grid power: {snapshot.instant_grid_power_w}W")
else:
print(f"Panel feed: {snapshot.instant_grid_power_w}W")
print(f"Grid power: {snapshot.power_flow_grid}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())
Connection State Monitoring
Push consumers that need to react to broker disconnect/reconnect events — for example, to mark downstream entities offline within a second of a dropped connection rather than waiting on a fallback poll — can register a connection callback. The callback
fires False on disconnect and True on reconnect, edge-only (no synthetic call at registration time):
def on_connection_change(connected: bool) -> None:
if connected:
print("Broker connection restored")
else:
print("Broker connection lost")
unsubscribe_connection = client.register_connection_callback(on_connection_change)
# Later, during teardown:
unsubscribe_connection()
To check the current connection state on demand (for example, just after registering), call await client.ping().
When the client is not fully live (broker disconnected, or Homie device not yet ready), await client.get_snapshot() raises SpanPanelStaleDataError instead of returning cached data. Treat that exception as the canonical "panel currently unreachable"
signal — see Error Handling below.
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.
Passing 0 (or any non-positive value) disables debounce and dispatches a snapshot for every incoming property message — real-time mode, intended for fast consumers.
# Reduce snapshot frequency to every 2 seconds
client.set_snapshot_interval(2.0)
# Real-time dispatch — every property update triggers a callback
client.set_snapshot_interval(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) during initial connect |
SpanPanelStaleDataError |
get_snapshot() called while the broker is disconnected or the Homie device has not reached ready |
SpanPanelTimeoutError |
Request or connection timed out |
SpanPanelValidationError |
Data validation failure |
SpanPanelAPIError |
Unexpected HTTP response from v2 endpoints |
SpanPanelServerError |
Panel answered 5xx, or answered 200 with a body that cannot be used — "not ready yet" |
Three more are specific to the hot-loading model, and they are separate because the remedy differs:
| Exception | Cause | Remedy |
|---|---|---|
SpanPanelAdapterMissingError |
Known schema, no installed parser for it | Install the named package |
SpanPanelSchemaVersionError |
The panel reports a data-model-version no adapter can even be named for |
Nothing to install yet — report the value |
SpanPanelAdapterIncompatibleError |
The required adapter is installed but was built against another contract | Installing more cannot help — align versions |
Reporting the third as the first would send someone to install a package they already have.
SpanPanelStaleDataError is distinct from SpanPanelConnectionError: the former means the client is running but data cannot be trusted right now (transient disconnect, or panel-declared not-ready); the latter means the initial connect failed and the
client cannot be used at all. SpanPanelServerError covers the whole 5xx class deliberately: a booting panel brings its network stack and reverse proxy up before the application behind them, so it answers rather than refuses, and that has to be
distinguishable from a 4xx that will not fix itself on its own.
from span_panel_api import (
SpanPanelAuthError,
SpanPanelConnectionError,
SpanPanelStaleDataError,
)
try:
client = await create_span_client(host="192.168.1.100", passphrase="wrong")
except SpanPanelAuthError:
print("Invalid passphrase")
except SpanPanelConnectionError:
print("Cannot reach panel")
# Later, during normal operation:
try:
snapshot = await client.get_snapshot()
except SpanPanelStaleDataError as err:
# Broker dropped or panel declared not-ready — fall back to last-known
# data, a grace-period value, or mark downstream state unavailable.
print(f"Snapshot unavailable: {err}")
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 |
Reference Payloads
Captures of what a panel actually serves, shipped as package data so a consumer can check its own assumptions against real bytes without vendoring a copy that silently goes stale:
from span_panel_api.reference_payloads import homie_schema, homie_schema_types
document = homie_schema() # the captured GET /api/v2/homie/schema response
types = homie_schema_types() # its `types` map, typed as HomieSchemaTypes
homie_schema_types() returns exactly what span_panel_api_schema_0.field_metadata.build_field_metadata accepts, so building real adapter metadata to compare against is two lines and no file handling.
The parent/child device tree is the schema_1 counterpart and ships from that adapter, with the parser that can interpret it:
from span_panel_api_schema_1.reference_payloads import devices_from_tree, parent_child_tree
devices = devices_from_tree(parent_child_tree())
Each payload carries the version of the release it shipped in. Pin a version and you read the bytes that version was written against.
Project Structure
One repository, three distributions. The bootstrap is at the root; each parser is a workspace member under packages/, published separately and versioned on its own axis.
src/span_panel_api/ # distribution: span-panel-api (no parser)
├── __init__.py # Public API exports
├── _http.py # Shared httpx plumbing / client ownership rules
├── adapters.py # installed_adapter_keys(), resolve_adapter() — metadata, then lazy import
├── auth.py # v2 HTTP provisioning (register, cert, schema, passphrase)
├── const.py # Panel state constants (DSM, relay)
├── detection.py # detect_api_version() → DetectionResult
├── dispatch.py # select_adapter_key() — what does this panel need?
├── exceptions.py # Exception hierarchy
├── factory.py # create_span_client() → SpanMqttClient
├── models.py # Snapshot dataclasses (panel, circuit, battery, PV, EVSE, MID, adopted)
├── phase_validation.py # Electrical phase utilities
├── protocol.py # PEP 544 protocols, SchemaAdapter, PanelCapability flags
├── schema_drift.py # Reporting a panel that outruns what we can read
├── reference_payloads/ # Captured GET /api/v2/homie/schema, shipped as package data
└── mqtt/
├── __init__.py
├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern)
├── client.py # SpanMqttClient (transport + control protocols)
├── connection.py # AsyncMqttBridge (event-loop-driven, no threads)
├── const.py # MQTT/Homie constants + UUID helpers
└── models.py # MqttClientConfig, MqttTransport
packages/schema-0/ # distribution: span-panel-api-schema-0
└── src/span_panel_api_schema_0/
# Flat parser: HomiePropertyAccumulator, HomieLifecycle,
# HomieDeviceConsumer, field metadata, SCHEMA_ANCHOR
packages/schema-1/ # distribution: span-panel-api-schema-1
├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed
└── src/span_panel_api_schema_1/
# Parent/child parser: ControllerRoutes, snapshot mapper,
# adoption, catalog validator, spec_lock.json, reference payloads
Development
See DEVELOPMENT.md for setup, testing, and contribution guidelines.
License
MIT License - see LICENSE file for details.
Metadata
Release files for span-panel-api 3.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| span_panel_api-3.0.0.tar.gz | 300.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| span_panel_api-3.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 388.1 kB
Release files / span_panel_api-3.0.0.tar.gz
| Download URL | span_panel_api-3.0.0.tar.gz |
|---|---|
| Size | 300.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4403751ec0c39ac816ffb212117ee50d3f043b00fff01fd599e4e7118740f6d1
|
|
BLAKE2b-256 checksum How to use checksums |
22b2dbda850e8a1f0518dbac3861e65340e2b1a792c11a46515f06275b61c26c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 23, 2026.
Transparency logRelease files / span_panel_api-3.0.0-py3-none-any.whl
| Download URL | span_panel_api-3.0.0-py3-none-any.whl |
|---|---|
| Size | 88.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0a2b809c43cf6f76874a302f7c22b3383453d1dafcd05972ac51842fc0599e33
|
|
BLAKE2b-256 checksum How to use checksums |
014e83d8f9136b5601dbb9019e287d7fe1cbd9ae6d80c4012bd5e4ebf6026788
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 23, 2026.
Transparency log