A Python client library for the SPAN Panel API, using MQTT/Homie for real-time push-based panel state.
| Line | Status | Needs |
|---|---|---|
| 3.x | Current. Transport and dispatcher; the parser is a separate install. | v2 firmware, Python 3.14+ |
| 2.x | Superseded by 3.0.0 and no longer developed. Fixes land on 3.x only. | v2 firmware, Python 3.10+ |
| 1.x | Deprecated. | v1 firmware |
1.x is built on the SPAN v1 REST API, which SPAN retires when v1 firmware sunsets at the end of 2026. Nothing on that line will outlive the firmware it talks to.
2.x still works against v2 firmware, but it is closed to new work. Everything since — the parent/child data model, adoption, discovery, extension properties — landed on 3.x, and so will anything that comes next. Treat 2.x as a line to leave rather than a line to stay on.
Moving from 2.x to 3.x is an install rather than only an upgrade: 3.0.0 removed the bundled parser, so pip install -U span-panel-api alone leaves you with a client that connects and then raises SpanPanelAdapterMissingError. See
Installation for the extra to name.
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.
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. Current parent/child firmware reports the canonicalMAJOR.MINOR[.PATCH]form —1.0— which selectsschema_1outright. A value whose major is still unambiguous but whose form is not canonical (1,1_0) dispatches on that major and logs the deviation, so a firmware that changes format is visible before it is an outage. A value with no readable major (v1.0,x) raisesSpanPanelSchemaVersionErrorrather than guessing: falling back to flat would hand a parent/child panel to the flat parser, which does not fail — it produces plausible but wrong figures. - 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.
httpx— v2 authentication and detection endpointspaho-mqtt— MQTT/Homie transport (real-time push)pyyaml— YAML parsing for configuration and API payloads
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.
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 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.
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.
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.
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())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())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.
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",
)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()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)# 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")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}")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}")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)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}")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 |
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 HomieSchemaTypeshomie_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.
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
See DEVELOPMENT.md for setup, testing, and contribution guidelines.
MIT License - see LICENSE file for details.