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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,29 @@ v2.0.0 is a ground-up rewrite. The package connects to the SPAN Panel's on-devic
The `SpanMqttClient` connects to the panel's MQTT broker (MQTTS or WebSocket) and subscribes to the Homie device tree. A `HomieDeviceConsumer` state machine parses incoming topic updates into typed `SpanPanelSnapshot` dataclasses. Changes are pushed to
consumers via callbacks.

### 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 `NullLock` instances at setup time, eliminating lock contention since all access is single-threaded on the event loop.
- **`add_reader` / `add_writer`** — `AsyncMqttBridge` registers the MQTT socket with the event loop via `loop.add_reader()` and `loop.add_writer()`, calling paho's `loop_read()` / `loop_write()` directly from I/O callbacks rather than from a `loop_start()`
background thread.
- **Periodic misc** — A `loop.call_at()` timer fires every second to call `loop_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()`:

1. After the device reaches ready state, the client polls `HomieDeviceConsumer.circuit_nodes_missing_names()` every 250ms.
2. As retained name properties arrive, the consumer stores them. Once all circuit-type nodes have a name, the wait returns immediately.
3. 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:
Expand Down
4 changes: 4 additions & 0 deletions src/span_panel_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ class SpanBatterySnapshot:
# BESS metadata
vendor_name: str | None = None # bess/vendor-name
product_name: str | None = None # bess/product-name
model: str | None = None # bess/model
serial_number: str | None = None # bess/serial-number
software_version: str | None = None # bess/software-version
nameplate_capacity_kwh: float | None = None # bess/nameplate-capacity (kWh)
connected: bool | None = None # bess/connected


@dataclass(frozen=True, slots=True)
Expand Down
8 changes: 8 additions & 0 deletions src/span_panel_api/mqtt/homie.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,14 +328,22 @@ def _build_battery(self) -> SpanBatterySnapshot:

vn = self._get_prop(bess_node, "vendor-name")
pn = self._get_prop(bess_node, "product-name")
mdl = self._get_prop(bess_node, "model")
sn = self._get_prop(bess_node, "serial-number")
sw = self._get_prop(bess_node, "software-version")
nc = self._get_prop(bess_node, "nameplate-capacity")
conn = self._get_prop(bess_node, "connected")

return SpanBatterySnapshot(
soe_percentage=_parse_float(soc_str) if soc_str else None,
soe_kwh=_parse_float(soe_str) if soe_str else None,
vendor_name=vn if vn else None,
product_name=pn if pn else None,
model=mdl if mdl else None,
serial_number=sn if sn else None,
software_version=sw if sw else None,
nameplate_capacity_kwh=_parse_float(nc) if nc else None,
connected=conn.lower() == "true" if conn else None,
)

def _build_pv(self) -> SpanPVSnapshot:
Expand Down