Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.

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).

## [2.2.3] - 03/2026

### Changed

- **Panel size sourced from Homie schema** — `panel_size` is now derived from the circuit `space` property format in the Homie schema (`GET /api/v2/homie/schema`), which declares the valid range as `"1:N:1"` where N is the panel size. This replaces a
non-deterministic heuristic that inferred panel size from the highest occupied breaker tab, which would undercount when trailing positions were empty.
- **`SpanMqttClient.connect()` fetches schema internally** — the client automatically calls `get_homie_schema()` during `connect()` and passes the panel size to `HomieDeviceConsumer`. Callers no longer need to fetch or pass `panel_size`.
- **`SpanPanelSnapshot.panel_size`** — type changed from `int | None` to `int`; always populated from the schema
- **`V2HomieSchema.panel_size`** — new property that parses the schema's circuit space format to extract the authoritative panel size
- **`V2HomieSchema` exported** from package public API
- **`HomieDeviceConsumer` requires `panel_size`** — new required constructor parameter; unmapped tabs now fill to the schema-defined panel size rather than deriving from circuit data
- **`create_span_client()` simplified** — `panel_size` parameter removed; schema is fetched internally by `SpanMqttClient.connect()`

### Removed

- **MQTT `core/panel-size` topic parsing** — removed from `HomieDeviceConsumer`; panel size comes from the schema, not a runtime MQTT property

## [2.0.0] - 02/2026

v2.0.0 is a ground-up rewrite. The REST/OpenAPI transport has been removed entirely in favor of MQTT/Homie — the SPAN Panel's native v2 protocol. This is a breaking change: all consumer code must be updated to use the new API surface.
Expand Down Expand Up @@ -227,6 +244,7 @@ Package versions prior to 2.0.0 depend on the SPAN v1 REST API. SPAN will sunset

| Version | Date | Transport | Summary |
| ---------- | ------- | ---------- | ---------------------------------------------------------------------------------- |
| **2.2.3** | 03/2026 | MQTT/Homie | Panel size from Homie schema; `panel_size` always populated on snapshot |
| **2.0.2** | 03/2026 | MQTT/Homie | EVSE (EV charger) snapshot model, Homie parsing, simulation support |
| **2.0.1** | 03/2026 | MQTT/Homie | Full BESS metadata parsing, README documentation |
| **2.0.0** | 02/2026 | MQTT/Homie | Ground-up rewrite: MQTT-only, protocol-based API, real-time push, PV/BESS metadata |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ 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)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "span-panel-api"
version = "2.2.2"
version = "2.2.3"
description = "A client library for SPAN Panel API"
authors = [
{name = "SpanPanel"}
Expand Down
2 changes: 2 additions & 0 deletions src/span_panel_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
SpanPanelSnapshot,
SpanPVSnapshot,
V2AuthResponse,
V2HomieSchema,
V2StatusInfo,
)
from .mqtt import MqttClientConfig, SpanMqttClient
Expand Down Expand Up @@ -66,6 +67,7 @@
"detect_api_version",
# v2 auth
"V2AuthResponse",
"V2HomieSchema",
"V2StatusInfo",
"download_ca_cert",
"get_homie_schema",
Expand Down
31 changes: 30 additions & 1 deletion src/span_panel_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ class V2StatusInfo:
firmware_version: str


_CIRCUIT_TYPE_KEY = "energy.ebus.device.circuit"


@dataclass(frozen=True, slots=True)
class V2HomieSchema:
"""Response from GET /api/v2/homie/schema."""
Expand All @@ -118,6 +121,32 @@ class V2HomieSchema:
types_schema_hash: str # SHA-256, first 16 hex chars
types: dict[str, dict[str, object]] # {type_name: {prop_name: {attr: value}}}

@property
def panel_size(self) -> int:
"""Extract panel size from the circuit ``space`` property format.

The Homie schema defines ``space`` with ``"format": "min:max:step"``
(e.g. ``"1:32:1"``). The *max* value is the number of breaker spaces
in the panel.

Raises:
ValueError: If the space format is missing or unparseable.
"""
circuit_type = self.types.get(_CIRCUIT_TYPE_KEY, {})
space_prop = circuit_type.get("space")
if not isinstance(space_prop, dict):
raise ValueError(f"Schema missing '{_CIRCUIT_TYPE_KEY}/space' property")
fmt = space_prop.get("format")
if not isinstance(fmt, str):
raise ValueError(f"Schema '{_CIRCUIT_TYPE_KEY}/space' has no format string")
parts = fmt.split(":")
if len(parts) != 3:
raise ValueError(f"Unexpected space format '{fmt}', expected 'min:max:step'")
try:
return int(parts[1])
except ValueError as exc:
raise ValueError(f"Cannot parse max from space format '{fmt}'") from exc


@dataclass(frozen=True, slots=True)
class SpanPanelSnapshot:
Expand Down Expand Up @@ -146,6 +175,7 @@ class SpanPanelSnapshot:
eth0_link: bool # v1: direct | v2: core/ethernet
wlan_link: bool # v1: direct | v2: core/wifi
wwan_link: bool # v1: direct | v2: vendor-cloud == "CONNECTED"
panel_size: int # Total breaker spaces (from Homie schema space format)

# v2-native fields — None for REST transport
dominant_power_source: str | None = None # v2: core/dominant-power-source (settable)
Expand All @@ -156,7 +186,6 @@ class SpanPanelSnapshot:
main_breaker_rating_a: int | None = None # v2: core/breaker-rating (A)
wifi_ssid: str | None = None # v2: core/wifi-ssid
vendor_cloud: str | None = None # v2: core/vendor-cloud
panel_size: int | None = None # v2: core/panel-size (total breaker spaces)

# Power flows (None when node not present)
power_flow_pv: float | None = None # v2: power-flows/pv (W)
Expand Down
46 changes: 31 additions & 15 deletions src/span_panel_api/mqtt/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from collections.abc import Awaitable, Callable
import logging

from ..auth import get_homie_schema
from ..exceptions import SpanPanelConnectionError, SpanPanelServerError
from ..models import SpanPanelSnapshot
from ..protocol import PanelCapability
Expand Down Expand Up @@ -43,14 +44,20 @@ def __init__(
self._snapshot_interval = snapshot_interval

self._bridge: AsyncMqttBridge | None = None
self._homie = HomieDeviceConsumer(serial_number)
self._homie: HomieDeviceConsumer | None = None
self._streaming = False
self._snapshot_callbacks: list[Callable[[SpanPanelSnapshot], Awaitable[None]]] = []
self._ready_event: asyncio.Event | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._background_tasks: set[asyncio.Task[None]] = set()
self._snapshot_timer: asyncio.TimerHandle | None = None

def _require_homie(self) -> HomieDeviceConsumer:
"""Return the HomieDeviceConsumer, raising if not yet connected."""
if self._homie is None:
raise SpanPanelConnectionError("Client not connected — call connect() first")
return self._homie

# -- SpanPanelClientProtocol -------------------------------------------

@property
Expand All @@ -72,10 +79,11 @@ async def connect(self) -> None:
"""Connect to MQTT broker and wait for Homie device ready.

Flow:
1. Create AsyncMqttBridge with broker credentials
2. Connect to MQTT broker
3. Subscribe to ebus/5/{serial}/#
4. Wait for $state==ready and $description parsed
1. Fetch Homie schema to determine panel size
2. Create AsyncMqttBridge with broker credentials
3. Connect to MQTT broker
4. Subscribe to ebus/5/{serial}/#
5. Wait for $state==ready and $description parsed

Raises:
SpanPanelConnectionError: Cannot connect or device not ready
Expand All @@ -84,6 +92,10 @@ async def connect(self) -> None:
self._loop = asyncio.get_running_loop()
self._ready_event = asyncio.Event()

# Fetch schema to determine panel size before processing any messages
schema = await get_homie_schema(self._host)
self._homie = HomieDeviceConsumer(self._serial_number, schema.panel_size)

_LOGGER.debug(
"MQTT: Creating bridge to %s:%s (serial=%s)",
self._broker_config.broker_host,
Expand Down Expand Up @@ -145,7 +157,7 @@ async def close(self) -> None:

async def ping(self) -> bool:
"""Check if MQTT connection is alive and device is ready."""
if self._bridge is None:
if self._bridge is None or self._homie is None:
return False
return self._bridge.is_connected() and self._homie.is_ready()

Expand All @@ -154,7 +166,7 @@ async def get_snapshot(self) -> SpanPanelSnapshot:

No network call — snapshot is built from in-memory property values.
"""
return self._homie.build_snapshot()
return self._require_homie().build_snapshot()

# -- CircuitControlProtocol --------------------------------------------

Expand Down Expand Up @@ -188,7 +200,7 @@ async def set_dominant_power_source(self, value: str) -> None:
Args:
value: DPS enum value (GRID, BATTERY, NONE, GENERATOR, PV)
"""
core_node = self._homie.find_node_by_type(TYPE_CORE)
core_node = self._require_homie().find_node_by_type(TYPE_CORE)
if core_node is None:
raise SpanPanelServerError("Core node not found in panel topology")
topic = PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=core_node, prop="dominant-power-source")
Expand Down Expand Up @@ -228,15 +240,18 @@ async def stop_streaming(self) -> None:

def _on_message(self, topic: str, payload: str) -> None:
"""Handle incoming MQTT message (called from asyncio loop)."""
was_ready = self._homie.is_ready()
self._homie.handle_message(topic, payload)
homie = self._homie
if homie is None:
return
was_ready = homie.is_ready()
homie.handle_message(topic, payload)

# Check if device just became ready
if not was_ready and self._homie.is_ready() and self._ready_event is not None:
if not was_ready and homie.is_ready() and self._ready_event is not None:
self._ready_event.set()

# Dispatch snapshot callbacks if streaming
if self._streaming and self._homie.is_ready() and self._loop is not None:
if self._streaming and homie.is_ready() and self._loop is not None:
if self._snapshot_interval <= 0:
# No debounce — dispatch immediately (backward compat)
self._create_dispatch_task()
Expand All @@ -263,15 +278,16 @@ async def _wait_for_circuit_names(self, timeout: float) -> None:
returns as soon as all circuit names are populated, or when the
timeout elapses (non-fatal — entities will use fallback names).
"""
homie = self._require_homie()
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
missing = self._homie.circuit_nodes_missing_names()
missing = homie.circuit_nodes_missing_names()
if not missing:
_LOGGER.debug("All circuit names received")
return
await asyncio.sleep(_CIRCUIT_NAMES_POLL_INTERVAL_S)

still_missing = self._homie.circuit_nodes_missing_names()
still_missing = homie.circuit_nodes_missing_names()
if still_missing:
_LOGGER.warning(
"Timed out waiting for circuit names (%d still missing): %s",
Expand Down Expand Up @@ -313,7 +329,7 @@ def set_snapshot_interval(self, interval: float) -> None:

async def _dispatch_snapshot(self) -> None:
"""Build snapshot and send to all registered callbacks."""
snapshot = self._homie.build_snapshot()
snapshot = self._require_homie().build_snapshot()
for cb in list(self._snapshot_callbacks):
try:
await cb(snapshot)
Expand Down
30 changes: 10 additions & 20 deletions src/span_panel_api/mqtt/homie.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ class HomieDeviceConsumer:
(guaranteed by AsyncMqttBridge's call_soon_threadsafe dispatch).
"""

def __init__(self, serial_number: str) -> None:
def __init__(self, serial_number: str, panel_size: int) -> None:
self._serial_number = serial_number
self._panel_size = panel_size
self._topic_prefix = f"{TOPIC_PREFIX}/{serial_number}"

self._state: str = ""
Expand Down Expand Up @@ -440,28 +441,21 @@ def _derive_run_config(self, dsm_state: str, grid_islandable: bool | None, dps:

return "UNKNOWN"

def _build_unmapped_tabs(self, circuits: dict[str, SpanCircuitSnapshot]) -> dict[str, SpanCircuitSnapshot]:
def _build_unmapped_tabs(
self,
circuits: dict[str, SpanCircuitSnapshot],
) -> dict[str, SpanCircuitSnapshot]:
"""Synthesize unmapped tab entries for breaker positions with no circuit.

Determines panel size from the highest occupied tab, then creates
zero-power SpanCircuitSnapshot entries for unoccupied positions.
Creates zero-power SpanCircuitSnapshot entries for unoccupied positions
up to ``self._panel_size``.
"""
# Collect all occupied tabs from commissioned circuits
occupied_tabs: set[int] = set()
for circuit in circuits.values():
occupied_tabs.update(circuit.tabs)

if not occupied_tabs:
return {}

# Panel size is the highest occupied tab (rounded up to even
# to cover both bus bar sides)
max_tab = max(occupied_tabs)
panel_size = max_tab if max_tab % 2 == 0 else max_tab + 1

# Synthesize entries for unoccupied positions
unmapped: dict[str, SpanCircuitSnapshot] = {}
for tab in range(1, panel_size + 1):
for tab in range(1, self._panel_size + 1):
if tab not in occupied_tabs:
circuit_id = f"unmapped_tab_{tab}"
unmapped[circuit_id] = SpanCircuitSnapshot(
Expand Down Expand Up @@ -500,7 +494,6 @@ def _build_snapshot(self) -> SpanPanelSnapshot:
main_breaker: int | None = None
wifi_ssid: str | None = None
vendor_cloud: str | None = None
panel_size: int | None = None

if core_node is not None:
firmware = self._get_prop(core_node, "software-version")
Expand Down Expand Up @@ -531,9 +524,6 @@ def _build_snapshot(self) -> SpanPanelSnapshot:
ws = self._get_prop(core_node, "wifi-ssid")
wifi_ssid = ws if ws else None

ps = self._get_prop(core_node, "panel-size")
panel_size = _parse_int(ps) if ps else None

# Upstream lugs → main meter (grid connection)
# imported-energy = energy imported from the grid = consumed by the house
# exported-energy = energy exported to the grid = produced (solar)
Expand Down Expand Up @@ -638,6 +628,7 @@ def _build_snapshot(self) -> SpanPanelSnapshot:
eth0_link=eth0,
wlan_link=wlan,
wwan_link=wwan_connected,
panel_size=self._panel_size,
dominant_power_source=dominant_power_source,
grid_state=grid_state,
grid_islandable=grid_islandable,
Expand All @@ -646,7 +637,6 @@ def _build_snapshot(self) -> SpanPanelSnapshot:
main_breaker_rating_a=main_breaker,
wifi_ssid=wifi_ssid,
vendor_cloud=vendor_cloud,
panel_size=panel_size,
power_flow_pv=power_flow_pv,
power_flow_battery=power_flow_battery,
power_flow_grid=power_flow_grid,
Expand Down
13 changes: 13 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from paho.mqtt.client import ConnectFlags
from paho.mqtt.reasoncodes import ReasonCode

from span_panel_api.models import V2HomieSchema
from span_panel_api.mqtt.const import TOPIC_PREFIX, TYPE_CORE

# ---------------------------------------------------------------------------
Expand All @@ -25,6 +26,17 @@
# Minimal Homie description that makes the device "ready"
MINIMAL_DESCRIPTION = json.dumps({"nodes": {"core": {"type": TYPE_CORE}}})

# Mock schema for SpanMqttClient.connect() — panel_size=32
_MOCK_SCHEMA = V2HomieSchema(
firmware_version="test",
types_schema_hash="sha256:test",
types={
"energy.ebus.device.circuit": {
"space": {"datatype": "integer", "format": "1:32:1"},
},
},
)


# ---------------------------------------------------------------------------
# Mock MQTT client fixture
Expand Down Expand Up @@ -89,6 +101,7 @@ def _reconnect() -> int:
patch("span_panel_api.mqtt.connection.AsyncMQTTClient") as cls,
patch("span_panel_api.mqtt.connection.download_ca_cert", return_value="FAKE-PEM"),
patch("span_panel_api.mqtt.connection.tempfile") as mock_tempfile,
patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_MOCK_SCHEMA),
):
# Make tempfile return a mock file object
mock_tmp = MagicMock()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_auth_and_simulation_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def test_invalid_with_custom_default(self) -> None:

class TestHomieCallbackUnregister:
def test_unregister_removes_callback(self) -> None:
consumer = HomieDeviceConsumer("test-serial")
consumer = HomieDeviceConsumer("test-serial", panel_size=32)
cb = AsyncMock()
unregister = consumer.register_property_callback(cb)
unregister()
Expand Down
Loading