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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ 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.6.1] - 04/2026

### Changed

- **`get_fqdn()` returns `str | None`** — `None` now distinguishes "no FQDN configured" (HTTP 404 or missing field) from an explicit empty string. Callers that treated `""` as "not registered" must update to check for `None`.
- **Connection callback errors logged at WARNING** — `SpanMqttClient._on_connection_change` now logs callback exceptions via `_LOGGER.warning(..., exc_info=True)` instead of `_LOGGER.exception(...)`, consistent with `_dispatch_snapshot`.
- **Reconnect loop catches all exceptions** — `AsyncMqttBridge._reconnect_loop` no longer silently drops on non-`OSError` failures (e.g. `WebsocketConnectionError`, `ssl.SSLError`). All exceptions are logged at WARNING and the loop keeps backing off.
- **Abnormal MQTT disconnects logged at WARNING** — disconnects where `reason_code.is_failure` is true now log at WARNING; clean disconnects continue to log at DEBUG.

### Fixed

- **CA certificate no longer written to disk** — `AsyncMqttBridge.connect()` builds the `ssl.SSLContext` from the fetched PEM via `cadata`, eliminating the temp-file lifecycle (and the small leak window on unexpected process exit) that the prior
`tls_set(ca_certs=path)` path required.
- **Deprecated `asyncio.get_event_loop()` removed** — `_wait_for_circuit_names` now uses `time.monotonic()`. The previous code emitted a `DeprecationWarning` on Python 3.12+.
- **Negative-zero on circuit `instant_power_w`** — explicit guard replaces a cryptic `-raw or 0.0` idiom in `HomieDeviceConsumer._build_circuit`.
- **DSM grid-exchanging heuristic uses epsilon** — replaces `!= 0.0` float comparison with `abs(x) > 1.0 W`, so the `DSM_OFF_GRID` branch is actually reachable when no BESS is commissioned and lugs readings hover near zero.
- **`SpanPanelAPIError.__str__` override removed** — the override silently hid exception args beyond the first; default `Exception.__str__` is now used.
- **Paho lock-layout check at import** — `span_panel_api.mqtt.async_client` verifies on import that the `_PAHO_LOCK_ATTRS` list exactly matches paho's `*_mutex` attributes. Raises `RuntimeError` (not `assert`, so `python -O` does not bypass it) on drift.

### Documentation

- **`register_v2()`** — docstring now warns that each call creates a new client entry on the panel; callers should persist and reuse the returned `V2AuthResponse` rather than re-registering on every restart.
- **Stale simulation transport references removed** from `protocol.py` and `models.py` module docstrings.

## [2.6.0] - 04/2026

### Added
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,14 @@ 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.

```python
# 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
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.6.0"
version = "2.6.1"
description = "A client library for SPAN Panel API"
authors = [
{name = "SpanPanel"}
Expand Down
19 changes: 15 additions & 4 deletions src/span_panel_api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ async def register_v2(
If ``passphrase`` is provided, it is sent as ``hopPassphrase``; omitting
it enables door-bypass registration.

.. note::
Every call creates a new registered client entry on the panel. Callers
should persist and reuse the returned ``V2AuthResponse`` rather than
re-registering on every restart — otherwise stale entries will
accumulate over the panel's lifetime.

Args:
host: IP address or hostname of the SPAN Panel
name: Client display name base (e.g., "home-assistant"); a UUID suffix is appended
Expand Down Expand Up @@ -310,7 +316,7 @@ async def get_fqdn(
timeout: float = 10.0,
port: int = 80,
httpx_client: httpx.AsyncClient | None = None,
) -> str:
) -> str | None:
"""Retrieve the currently registered FQDN from the SPAN Panel.

Args:
Expand All @@ -321,7 +327,9 @@ async def get_fqdn(
httpx_client: Optional shared ``httpx.AsyncClient``; not closed by this function.

Returns:
The registered FQDN, or empty string if none is configured
The registered FQDN string, or ``None`` when no FQDN is configured
(HTTP 404 or missing ``ebusTlsFqdn`` field). An empty string is only
returned when the panel reports an explicit empty FQDN value.

Raises:
SpanPanelAuthError: Token invalid or expired
Expand All @@ -344,13 +352,16 @@ async def get_fqdn(
raise SpanPanelAuthError(f"Authentication failed (HTTP {response.status_code})")

if response.status_code == 404:
return ""
return None

if response.status_code != 200:
raise SpanPanelAPIError(f"Failed to get FQDN: HTTP {response.status_code}")

data: dict[str, object] = response.json()
return _str(data.get("ebusTlsFqdn"))
raw = data.get("ebusTlsFqdn")
if raw is None:
return None
return str(raw)


async def delete_fqdn(
Expand Down
3 changes: 0 additions & 3 deletions src/span_panel_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,6 @@ def __init__(self, message: str, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code

def __str__(self) -> str:
return self.args[0] if self.args else ""


class SpanPanelServerError(SpanPanelAPIError):
"""Server error (500)."""
Expand Down
6 changes: 3 additions & 3 deletions src/span_panel_api/models.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Transport-agnostic snapshot models for SPAN Panel state.

These dataclasses represent panel state regardless of how it was obtained
(REST polling or MQTT push). Energy and power sign conventions are
normalized at the transport boundary — consumers see a consistent view.
These dataclasses represent panel state as produced by the MQTT/Homie
transport. Energy and power sign conventions are normalized at the
transport boundary — consumers see a consistent view.

All snapshots are immutable (frozen) and memory-efficient (slots).
"""
Expand Down
24 changes: 24 additions & 0 deletions src/span_panel_api/mqtt/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from types import TracebackType

from paho.mqtt.client import Client as MQTTClient
from paho.mqtt.enums import CallbackAPIVersion

_PAHO_LOCK_ATTRS = (
"_in_callback_mutex",
Expand All @@ -23,6 +24,29 @@
)


def _verify_paho_lock_attrs() -> None:
"""Verify paho-mqtt's lock layout matches the list we monkey-patch.

Runs once at import. Raises ``RuntimeError`` if any expected attribute
is missing (paho renamed/removed one) or if paho grew a new lock we
don't yet patch. Running ``python -O`` does not bypass this check.
"""
probe = MQTTClient(callback_api_version=CallbackAPIVersion.VERSION2)
expected = set(_PAHO_LOCK_ATTRS)
found = {name for name in vars(probe) if name.endswith("_mutex")}
missing = expected - found
extra = found - expected
if missing or extra:
raise RuntimeError(
"paho-mqtt lock attributes changed — NullLock monkey-patch is out of date. "
f"missing={sorted(missing)}, extra={sorted(extra)}. "
"Update _PAHO_LOCK_ATTRS in span_panel_api.mqtt.async_client."
)


_verify_paho_lock_attrs()


class NullLock:
"""No-op lock for single-threaded event loop execution.

Expand Down
14 changes: 9 additions & 5 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 contextlib
import logging
import time

from ..auth import get_homie_schema
from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError
Expand Down Expand Up @@ -324,7 +325,7 @@ def _on_message(self, topic: str, payload: str) -> None:
# Dispatch snapshot callbacks if streaming
if self._streaming and homie.is_ready() and self._loop is not None:
if self._snapshot_interval <= 0:
# No debounce — dispatch immediately (backward compat)
# Real-time mode — dispatch immediately, no debounce.
self._create_dispatch_task()
elif self._snapshot_timer is None:
# Schedule debounced dispatch
Expand Down Expand Up @@ -366,7 +367,7 @@ def _on_connection_change(self, connected: bool) -> None:
try:
cb(connected)
except Exception: # pylint: disable=broad-exception-caught
_LOGGER.exception("Connection callback raised")
_LOGGER.warning("Connection callback raised", exc_info=True)

async def _wait_for_circuit_names(self, timeout: float) -> None:
"""Wait for all circuit-like nodes to have a ``name`` property.
Expand All @@ -377,8 +378,8 @@ async def _wait_for_circuit_names(self, timeout: float) -> None:
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:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
missing = homie.circuit_nodes_missing_names()
if not missing:
_LOGGER.debug("All circuit names received")
Expand Down Expand Up @@ -419,7 +420,10 @@ def set_snapshot_interval(self, interval: float) -> None:
"""Update the snapshot debounce interval at runtime.

Args:
interval: Seconds between snapshot dispatches. 0 = no debounce.
interval: Seconds between snapshot dispatches. ``0`` (or any
non-positive value) disables debounce and dispatches a
snapshot for every incoming property message — real-time
mode, intended for fast consumers.
"""
self._snapshot_interval = interval
# Cancel any pending timer so the new interval takes effect on next message
Expand Down
Loading