span-panel-api 2.6.0__tar.gz → 2.6.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/CHANGELOG.md +32 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/PKG-INFO +6 -1
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/README.md +5 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/pyproject.toml +1 -1
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/auth.py +15 -4
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/exceptions.py +0 -3
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/models.py +3 -3
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/async_client.py +24 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/client.py +9 -5
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/connection.py +97 -100
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/homie.py +11 -3
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/protocol.py +3 -3
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/conftest.py +1 -7
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_detection_auth.py +29 -1
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_mqtt_client_connection.py +1 -1
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_mqtt_connect_flow.py +37 -5
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_mqtt_debounce.py +46 -28
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/uv.lock +19 -1
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.codefactor +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.codefactor.yml +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.deps-installed +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.github/dependabot.yml +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.github/workflows/ci.yml +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.github/workflows/dependabot-auto-approve.yml +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.github/workflows/dependabot-auto-merge.yml +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.github/workflows/release.yml +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.gitignore +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.markdownlint-cli2.jsonc +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.markdownlint.json +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.pre-commit-config.yaml +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.prettierrc.json +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.vscode/extensions.json +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.vscode/tasks.json +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/DEVELOPMENT.md +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/LICENSE +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/SECURITY.md +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/conftest.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/developer_attribute_readme.md +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/openapi.json +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/pytest.ini +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/pytest_output.log +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/scripts/__init__.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/scripts/coverage.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/scripts/format.sh +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/scripts/format_markdown.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/scripts/test_live_auth.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/setup-hooks.sh +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/__init__.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/const.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/accumulator.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/field_metadata.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/fixtures/v2/homie_schema.json +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_accumulator.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_exceptions.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_field_metadata.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_mqtt_homie.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_protocol_models.py +0 -0
|
@@ -4,6 +4,38 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
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).
|
|
6
6
|
|
|
7
|
+
## [2.6.2] - 04/2026
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Reconnect loop log noise reduced** — `SpanMqttClient._reconnect_loop` now splits the catch-all exception handler in two: expected transient failures (`OSError` family — refused connection, DNS miss, socket timeout, `ssl.SSLError`) log a one-line
|
|
12
|
+
WARNING with the exception repr, while unexpected exceptions retain the full traceback via `exc_info=True`. The common "panel offline" case no longer buries logs in paho/stdlib stack frames that add no diagnostic signal; genuinely unknown failures still
|
|
13
|
+
surface full tracebacks for support-ticket triage.
|
|
14
|
+
|
|
15
|
+
## [2.6.1] - 04/2026
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **`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`.
|
|
20
|
+
- **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`.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **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
|
|
27
|
+
`tls_set(ca_certs=path)` path required.
|
|
28
|
+
- **Deprecated `asyncio.get_event_loop()` removed** — `_wait_for_circuit_names` now uses `time.monotonic()`. The previous code emitted a `DeprecationWarning` on Python 3.12+.
|
|
29
|
+
- **Negative-zero on circuit `instant_power_w`** — explicit guard replaces a cryptic `-raw or 0.0` idiom in `HomieDeviceConsumer._build_circuit`.
|
|
30
|
+
- **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.
|
|
31
|
+
- **`SpanPanelAPIError.__str__` override removed** — the override silently hid exception args beyond the first; default `Exception.__str__` is now used.
|
|
32
|
+
- **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.
|
|
33
|
+
|
|
34
|
+
### Documentation
|
|
35
|
+
|
|
36
|
+
- **`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.
|
|
37
|
+
- **Stale simulation transport references removed** from `protocol.py` and `models.py` module docstrings.
|
|
38
|
+
|
|
7
39
|
## [2.6.0] - 04/2026
|
|
8
40
|
|
|
9
41
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 2.6.
|
|
3
|
+
Version: 2.6.2
|
|
4
4
|
Summary: A client library for SPAN Panel API
|
|
5
5
|
Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
|
|
6
6
|
Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
|
|
@@ -250,9 +250,14 @@ await client.connect()
|
|
|
250
250
|
|
|
251
251
|
`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.
|
|
252
252
|
|
|
253
|
+
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.
|
|
254
|
+
|
|
253
255
|
```python
|
|
254
256
|
# Reduce snapshot frequency to every 2 seconds
|
|
255
257
|
client.set_snapshot_interval(2.0)
|
|
258
|
+
|
|
259
|
+
# Real-time dispatch — every property update triggers a callback
|
|
260
|
+
client.set_snapshot_interval(0)
|
|
256
261
|
```
|
|
257
262
|
|
|
258
263
|
### Circuit Control
|
|
@@ -235,9 +235,14 @@ await client.connect()
|
|
|
235
235
|
|
|
236
236
|
`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.
|
|
237
237
|
|
|
238
|
+
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.
|
|
239
|
+
|
|
238
240
|
```python
|
|
239
241
|
# Reduce snapshot frequency to every 2 seconds
|
|
240
242
|
client.set_snapshot_interval(2.0)
|
|
243
|
+
|
|
244
|
+
# Real-time dispatch — every property update triggers a callback
|
|
245
|
+
client.set_snapshot_interval(0)
|
|
241
246
|
```
|
|
242
247
|
|
|
243
248
|
### Circuit Control
|
|
@@ -47,6 +47,12 @@ async def register_v2(
|
|
|
47
47
|
If ``passphrase`` is provided, it is sent as ``hopPassphrase``; omitting
|
|
48
48
|
it enables door-bypass registration.
|
|
49
49
|
|
|
50
|
+
.. note::
|
|
51
|
+
Every call creates a new registered client entry on the panel. Callers
|
|
52
|
+
should persist and reuse the returned ``V2AuthResponse`` rather than
|
|
53
|
+
re-registering on every restart — otherwise stale entries will
|
|
54
|
+
accumulate over the panel's lifetime.
|
|
55
|
+
|
|
50
56
|
Args:
|
|
51
57
|
host: IP address or hostname of the SPAN Panel
|
|
52
58
|
name: Client display name base (e.g., "home-assistant"); a UUID suffix is appended
|
|
@@ -310,7 +316,7 @@ async def get_fqdn(
|
|
|
310
316
|
timeout: float = 10.0,
|
|
311
317
|
port: int = 80,
|
|
312
318
|
httpx_client: httpx.AsyncClient | None = None,
|
|
313
|
-
) -> str:
|
|
319
|
+
) -> str | None:
|
|
314
320
|
"""Retrieve the currently registered FQDN from the SPAN Panel.
|
|
315
321
|
|
|
316
322
|
Args:
|
|
@@ -321,7 +327,9 @@ async def get_fqdn(
|
|
|
321
327
|
httpx_client: Optional shared ``httpx.AsyncClient``; not closed by this function.
|
|
322
328
|
|
|
323
329
|
Returns:
|
|
324
|
-
The registered FQDN, or
|
|
330
|
+
The registered FQDN string, or ``None`` when no FQDN is configured
|
|
331
|
+
(HTTP 404 or missing ``ebusTlsFqdn`` field). An empty string is only
|
|
332
|
+
returned when the panel reports an explicit empty FQDN value.
|
|
325
333
|
|
|
326
334
|
Raises:
|
|
327
335
|
SpanPanelAuthError: Token invalid or expired
|
|
@@ -344,13 +352,16 @@ async def get_fqdn(
|
|
|
344
352
|
raise SpanPanelAuthError(f"Authentication failed (HTTP {response.status_code})")
|
|
345
353
|
|
|
346
354
|
if response.status_code == 404:
|
|
347
|
-
return
|
|
355
|
+
return None
|
|
348
356
|
|
|
349
357
|
if response.status_code != 200:
|
|
350
358
|
raise SpanPanelAPIError(f"Failed to get FQDN: HTTP {response.status_code}")
|
|
351
359
|
|
|
352
360
|
data: dict[str, object] = response.json()
|
|
353
|
-
|
|
361
|
+
raw = data.get("ebusTlsFqdn")
|
|
362
|
+
if raw is None:
|
|
363
|
+
return None
|
|
364
|
+
return str(raw)
|
|
354
365
|
|
|
355
366
|
|
|
356
367
|
async def delete_fqdn(
|
|
@@ -28,9 +28,6 @@ class SpanPanelAPIError(SpanPanelError):
|
|
|
28
28
|
super().__init__(message)
|
|
29
29
|
self.status_code = status_code
|
|
30
30
|
|
|
31
|
-
def __str__(self) -> str:
|
|
32
|
-
return self.args[0] if self.args else ""
|
|
33
|
-
|
|
34
31
|
|
|
35
32
|
class SpanPanelServerError(SpanPanelAPIError):
|
|
36
33
|
"""Server error (500)."""
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Transport-agnostic snapshot models for SPAN Panel state.
|
|
2
2
|
|
|
3
|
-
These dataclasses represent panel state
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
These dataclasses represent panel state as produced by the MQTT/Homie
|
|
4
|
+
transport. Energy and power sign conventions are normalized at the
|
|
5
|
+
transport boundary — consumers see a consistent view.
|
|
6
6
|
|
|
7
7
|
All snapshots are immutable (frozen) and memory-efficient (slots).
|
|
8
8
|
"""
|
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
from types import TracebackType
|
|
12
12
|
|
|
13
13
|
from paho.mqtt.client import Client as MQTTClient
|
|
14
|
+
from paho.mqtt.enums import CallbackAPIVersion
|
|
14
15
|
|
|
15
16
|
_PAHO_LOCK_ATTRS = (
|
|
16
17
|
"_in_callback_mutex",
|
|
@@ -23,6 +24,29 @@ _PAHO_LOCK_ATTRS = (
|
|
|
23
24
|
)
|
|
24
25
|
|
|
25
26
|
|
|
27
|
+
def _verify_paho_lock_attrs() -> None:
|
|
28
|
+
"""Verify paho-mqtt's lock layout matches the list we monkey-patch.
|
|
29
|
+
|
|
30
|
+
Runs once at import. Raises ``RuntimeError`` if any expected attribute
|
|
31
|
+
is missing (paho renamed/removed one) or if paho grew a new lock we
|
|
32
|
+
don't yet patch. Running ``python -O`` does not bypass this check.
|
|
33
|
+
"""
|
|
34
|
+
probe = MQTTClient(callback_api_version=CallbackAPIVersion.VERSION2)
|
|
35
|
+
expected = set(_PAHO_LOCK_ATTRS)
|
|
36
|
+
found = {name for name in vars(probe) if name.endswith("_mutex")}
|
|
37
|
+
missing = expected - found
|
|
38
|
+
extra = found - expected
|
|
39
|
+
if missing or extra:
|
|
40
|
+
raise RuntimeError(
|
|
41
|
+
"paho-mqtt lock attributes changed — NullLock monkey-patch is out of date. "
|
|
42
|
+
f"missing={sorted(missing)}, extra={sorted(extra)}. "
|
|
43
|
+
"Update _PAHO_LOCK_ATTRS in span_panel_api.mqtt.async_client."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_verify_paho_lock_attrs()
|
|
48
|
+
|
|
49
|
+
|
|
26
50
|
class NullLock:
|
|
27
51
|
"""No-op lock for single-threaded event loop execution.
|
|
28
52
|
|
|
@@ -11,6 +11,7 @@ import asyncio
|
|
|
11
11
|
from collections.abc import Awaitable, Callable
|
|
12
12
|
import contextlib
|
|
13
13
|
import logging
|
|
14
|
+
import time
|
|
14
15
|
|
|
15
16
|
from ..auth import get_homie_schema
|
|
16
17
|
from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError
|
|
@@ -324,7 +325,7 @@ class SpanMqttClient:
|
|
|
324
325
|
# Dispatch snapshot callbacks if streaming
|
|
325
326
|
if self._streaming and homie.is_ready() and self._loop is not None:
|
|
326
327
|
if self._snapshot_interval <= 0:
|
|
327
|
-
#
|
|
328
|
+
# Real-time mode — dispatch immediately, no debounce.
|
|
328
329
|
self._create_dispatch_task()
|
|
329
330
|
elif self._snapshot_timer is None:
|
|
330
331
|
# Schedule debounced dispatch
|
|
@@ -366,7 +367,7 @@ class SpanMqttClient:
|
|
|
366
367
|
try:
|
|
367
368
|
cb(connected)
|
|
368
369
|
except Exception: # pylint: disable=broad-exception-caught
|
|
369
|
-
_LOGGER.
|
|
370
|
+
_LOGGER.warning("Connection callback raised", exc_info=True)
|
|
370
371
|
|
|
371
372
|
async def _wait_for_circuit_names(self, timeout: float) -> None:
|
|
372
373
|
"""Wait for all circuit-like nodes to have a ``name`` property.
|
|
@@ -377,8 +378,8 @@ class SpanMqttClient:
|
|
|
377
378
|
timeout elapses (non-fatal — entities will use fallback names).
|
|
378
379
|
"""
|
|
379
380
|
homie = self._require_homie()
|
|
380
|
-
deadline =
|
|
381
|
-
while
|
|
381
|
+
deadline = time.monotonic() + timeout
|
|
382
|
+
while time.monotonic() < deadline:
|
|
382
383
|
missing = homie.circuit_nodes_missing_names()
|
|
383
384
|
if not missing:
|
|
384
385
|
_LOGGER.debug("All circuit names received")
|
|
@@ -419,7 +420,10 @@ class SpanMqttClient:
|
|
|
419
420
|
"""Update the snapshot debounce interval at runtime.
|
|
420
421
|
|
|
421
422
|
Args:
|
|
422
|
-
interval: Seconds between snapshot dispatches. 0
|
|
423
|
+
interval: Seconds between snapshot dispatches. ``0`` (or any
|
|
424
|
+
non-positive value) disables debounce and dispatches a
|
|
425
|
+
snapshot for every incoming property message — real-time
|
|
426
|
+
mode, intended for fast consumers.
|
|
423
427
|
"""
|
|
424
428
|
self._snapshot_interval = interval
|
|
425
429
|
# Cancel any pending timer so the new interval takes effect on next message
|
|
@@ -13,9 +13,7 @@ import asyncio
|
|
|
13
13
|
from collections.abc import Callable
|
|
14
14
|
from functools import partial
|
|
15
15
|
import logging
|
|
16
|
-
from pathlib import Path
|
|
17
16
|
import ssl
|
|
18
|
-
import tempfile
|
|
19
17
|
from typing import TYPE_CHECKING
|
|
20
18
|
|
|
21
19
|
import paho.mqtt.client as paho
|
|
@@ -42,6 +40,20 @@ if TYPE_CHECKING:
|
|
|
42
40
|
_LOGGER = logging.getLogger(__name__)
|
|
43
41
|
|
|
44
42
|
|
|
43
|
+
def _build_ssl_context(ca_pem: str) -> ssl.SSLContext:
|
|
44
|
+
"""Build an SSLContext that trusts only the provided panel CA.
|
|
45
|
+
|
|
46
|
+
The panel issues a private CA and a server cert signed by it. We do
|
|
47
|
+
not want to trust system CAs for this connection, so the context is
|
|
48
|
+
built fresh rather than via ``ssl.create_default_context()``.
|
|
49
|
+
"""
|
|
50
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
51
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
52
|
+
ctx.check_hostname = True
|
|
53
|
+
ctx.load_verify_locations(cadata=ca_pem)
|
|
54
|
+
return ctx
|
|
55
|
+
|
|
56
|
+
|
|
45
57
|
class AsyncMqttBridge:
|
|
46
58
|
"""Event-loop-driven paho-mqtt wrapper with async callback dispatch.
|
|
47
59
|
|
|
@@ -77,7 +89,6 @@ class AsyncMqttBridge:
|
|
|
77
89
|
self._connected = False
|
|
78
90
|
self._client: AsyncMQTTClient | None = None
|
|
79
91
|
self._connect_event: asyncio.Event | None = None
|
|
80
|
-
self._ca_cert_path: Path | None = None
|
|
81
92
|
|
|
82
93
|
self._misc_timer: asyncio.TimerHandle | None = None
|
|
83
94
|
self._should_reconnect = False
|
|
@@ -117,96 +128,83 @@ class AsyncMqttBridge:
|
|
|
117
128
|
|
|
118
129
|
# Fetch CA cert from panel for TLS
|
|
119
130
|
_LOGGER.debug("BRIDGE: Fetching CA cert from %s (use_tls=%s)", self._panel_host, self._use_tls)
|
|
120
|
-
|
|
121
|
-
ca_cert_path: Path | None = None
|
|
131
|
+
ssl_context: ssl.SSLContext | None = None
|
|
122
132
|
if self._use_tls:
|
|
123
133
|
try:
|
|
124
134
|
ca_pem = await download_ca_cert(self._panel_host, port=self._panel_http_port)
|
|
125
135
|
except (OSError, SpanPanelConnectionError, SpanPanelTimeoutError) as exc:
|
|
126
136
|
raise SpanPanelConnectionError(f"Failed to fetch CA certificate from {self._panel_host}") from exc
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
# Build the SSLContext from PEM data in memory — no temp file.
|
|
138
|
+
# A malformed PEM raises ssl.SSLError or ValueError; wrap both
|
|
139
|
+
# so callers only see the documented SpanPanelConnectionError.
|
|
140
|
+
try:
|
|
141
|
+
ssl_context = _build_ssl_context(ca_pem)
|
|
142
|
+
except (ssl.SSLError, ValueError) as exc:
|
|
143
|
+
raise SpanPanelConnectionError(f"Failed to build SSL context for {self._panel_host}") from exc
|
|
144
|
+
|
|
145
|
+
self._client = AsyncMQTTClient(
|
|
146
|
+
callback_api_version=CallbackAPIVersion.VERSION2,
|
|
147
|
+
transport=self._transport,
|
|
148
|
+
reconnect_on_failure=False,
|
|
149
|
+
)
|
|
150
|
+
self._client.setup()
|
|
151
|
+
|
|
152
|
+
self._client.username_pw_set(self._username, self._password)
|
|
153
|
+
|
|
154
|
+
# Wire socket callbacks (async versions by default)
|
|
155
|
+
self._client.on_socket_close = self._async_on_socket_close
|
|
156
|
+
self._client.on_socket_unregister_write = self._async_on_socket_unregister_write
|
|
157
|
+
|
|
158
|
+
# Wire MQTT callbacks (run directly on event loop — no thread dispatch)
|
|
159
|
+
self._client.on_connect = self._on_connect
|
|
160
|
+
self._client.on_disconnect = self._on_disconnect
|
|
161
|
+
self._client.on_message = self._on_message
|
|
162
|
+
|
|
163
|
+
if ssl_context is not None:
|
|
164
|
+
self._client.tls_set_context(ssl_context)
|
|
165
|
+
|
|
166
|
+
# Connect in executor (blocking: DNS, TCP, TLS handshake).
|
|
167
|
+
# During executor connect, socket callbacks bridge to the event
|
|
168
|
+
# loop via call_soon_threadsafe.
|
|
169
|
+
def _blocking_connect() -> None:
|
|
170
|
+
if self._client is None:
|
|
171
|
+
raise RuntimeError("MQTT client not initialised before connect")
|
|
172
|
+
self._client.connect(
|
|
173
|
+
host=self._host,
|
|
174
|
+
port=self._port,
|
|
175
|
+
keepalive=MQTT_KEEPALIVE_S,
|
|
133
176
|
)
|
|
134
|
-
self._client.setup()
|
|
135
|
-
|
|
136
|
-
self._client.username_pw_set(self._username, self._password)
|
|
137
|
-
|
|
138
|
-
# Wire socket callbacks (async versions by default)
|
|
139
|
-
self._client.on_socket_close = self._async_on_socket_close
|
|
140
|
-
self._client.on_socket_unregister_write = self._async_on_socket_unregister_write
|
|
141
|
-
|
|
142
|
-
# Wire MQTT callbacks (run directly on event loop — no thread dispatch)
|
|
143
|
-
self._client.on_connect = self._on_connect
|
|
144
|
-
self._client.on_disconnect = self._on_disconnect
|
|
145
|
-
self._client.on_message = self._on_message
|
|
146
|
-
|
|
147
|
-
# TLS setup + connect in executor (blocking: temp file write,
|
|
148
|
-
# load_verify_locations, DNS, TCP, and TLS handshake).
|
|
149
|
-
# During executor connect, socket callbacks bridge to the event
|
|
150
|
-
# loop via call_soon_threadsafe.
|
|
151
|
-
def _blocking_tls_and_connect() -> None:
|
|
152
|
-
"""Write CA cert to temp file, configure TLS, and connect."""
|
|
153
|
-
nonlocal ca_cert_path
|
|
154
|
-
if self._client is None:
|
|
155
|
-
raise RuntimeError("MQTT client not initialised before connect")
|
|
156
|
-
if self._use_tls and ca_pem is not None:
|
|
157
|
-
tmp = tempfile.NamedTemporaryFile( # pylint: disable=consider-using-with # noqa: SIM115
|
|
158
|
-
mode="w", suffix=".pem", delete=False
|
|
159
|
-
)
|
|
160
|
-
tmp.write(ca_pem)
|
|
161
|
-
tmp.close()
|
|
162
|
-
ca_cert_path = Path(tmp.name)
|
|
163
|
-
self._client.tls_set(
|
|
164
|
-
ca_certs=str(ca_cert_path),
|
|
165
|
-
cert_reqs=ssl.CERT_REQUIRED,
|
|
166
|
-
tls_version=ssl.PROTOCOL_TLS_CLIENT,
|
|
167
|
-
)
|
|
168
|
-
self._client.connect(
|
|
169
|
-
host=self._host,
|
|
170
|
-
port=self._port,
|
|
171
|
-
keepalive=MQTT_KEEPALIVE_S,
|
|
172
|
-
)
|
|
173
177
|
|
|
178
|
+
try:
|
|
179
|
+
self._client.on_socket_open = self._on_socket_open_sync
|
|
180
|
+
self._client.on_socket_register_write = self._on_socket_register_write_sync
|
|
181
|
+
_LOGGER.debug("BRIDGE: Running connect in executor to %s:%s", self._host, self._port)
|
|
174
182
|
try:
|
|
175
|
-
self.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
await self.disconnect()
|
|
196
|
-
raise SpanPanelTimeoutError(f"Timed out connecting to MQTT broker at {self._host}:{self._port}") from exc
|
|
197
|
-
|
|
198
|
-
if not self._connected:
|
|
199
|
-
raise SpanPanelConnectionError(f"MQTT connection failed to {self._host}:{self._port}")
|
|
183
|
+
await self._loop.run_in_executor(None, _blocking_connect)
|
|
184
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
185
|
+
# paho raises OSError for TCP failures and transport-specific
|
|
186
|
+
# errors (e.g. WebsocketConnectionError) that do not inherit
|
|
187
|
+
# from OSError. Wrap all of them uniformly so callers only
|
|
188
|
+
# see the documented SpanPanelConnectionError.
|
|
189
|
+
raise SpanPanelConnectionError(f"Cannot connect to MQTT broker at {self._host}:{self._port}: {exc}") from exc
|
|
190
|
+
_LOGGER.debug("BRIDGE: Executor connect returned, waiting for CONNACK...")
|
|
191
|
+
finally:
|
|
192
|
+
# Switch to async-only socket callbacks now that we are
|
|
193
|
+
# back on the event loop thread.
|
|
194
|
+
self._client.on_socket_open = self._async_on_socket_open
|
|
195
|
+
self._client.on_socket_register_write = self._async_on_socket_register_write
|
|
196
|
+
|
|
197
|
+
# Wait for CONNACK
|
|
198
|
+
try:
|
|
199
|
+
await asyncio.wait_for(self._connect_event.wait(), timeout=MQTT_CONNECT_TIMEOUT_S)
|
|
200
|
+
except asyncio.TimeoutError as exc:
|
|
201
|
+
await self.disconnect()
|
|
202
|
+
raise SpanPanelTimeoutError(f"Timed out connecting to MQTT broker at {self._host}:{self._port}") from exc
|
|
200
203
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
self._ca_cert_path = ca_cert_path
|
|
204
|
+
if not self._connected:
|
|
205
|
+
raise SpanPanelConnectionError(f"MQTT connection failed to {self._host}:{self._port}")
|
|
204
206
|
|
|
205
|
-
|
|
206
|
-
# Clean up temp CA cert file on failure only
|
|
207
|
-
if ca_cert_path is not None:
|
|
208
|
-
self._remove_cert_file(ca_cert_path)
|
|
209
|
-
raise
|
|
207
|
+
self._initial_connect_done = True
|
|
210
208
|
|
|
211
209
|
async def disconnect(self) -> None:
|
|
212
210
|
"""Disconnect from the MQTT broker."""
|
|
@@ -229,25 +227,11 @@ class AsyncMqttBridge:
|
|
|
229
227
|
self._client = None
|
|
230
228
|
self._initial_connect_done = False
|
|
231
229
|
|
|
232
|
-
cert_path = self._ca_cert_path
|
|
233
|
-
self._ca_cert_path = None
|
|
234
|
-
if cert_path is not None:
|
|
235
|
-
loop = asyncio.get_running_loop()
|
|
236
|
-
await loop.run_in_executor(None, partial(self._remove_cert_file, cert_path))
|
|
237
|
-
|
|
238
230
|
def subscribe(self, topic: str, qos: int = 0) -> None:
|
|
239
231
|
"""Subscribe to a topic. Must be called after connect()."""
|
|
240
232
|
if self._client is not None:
|
|
241
233
|
self._client.subscribe(topic, qos=qos)
|
|
242
234
|
|
|
243
|
-
@staticmethod
|
|
244
|
-
def _remove_cert_file(path: Path) -> None:
|
|
245
|
-
"""Remove a temporary CA certificate file (safe to call from any thread)."""
|
|
246
|
-
try:
|
|
247
|
-
path.unlink()
|
|
248
|
-
except OSError:
|
|
249
|
-
_LOGGER.debug("Failed to remove temp CA cert file: %s", path)
|
|
250
|
-
|
|
251
235
|
def publish(self, topic: str, payload: str, qos: int = 1) -> None:
|
|
252
236
|
"""Publish a message. Must be called after connect()."""
|
|
253
237
|
if self._client is not None:
|
|
@@ -379,7 +363,10 @@ class AsyncMqttBridge:
|
|
|
379
363
|
) -> None:
|
|
380
364
|
"""Handle disconnect from broker."""
|
|
381
365
|
self._connected = False
|
|
382
|
-
|
|
366
|
+
if reason_code.is_failure:
|
|
367
|
+
_LOGGER.warning("MQTT disconnected abnormally: %s", reason_code)
|
|
368
|
+
else:
|
|
369
|
+
_LOGGER.debug("MQTT disconnected: %s", reason_code)
|
|
383
370
|
|
|
384
371
|
# Signal connect event if still waiting (socket closed before CONNACK)
|
|
385
372
|
if self._connect_event is not None and not self._connect_event.is_set():
|
|
@@ -420,8 +407,18 @@ class AsyncMqttBridge:
|
|
|
420
407
|
self._client.on_socket_open = self._on_socket_open_sync
|
|
421
408
|
self._client.on_socket_register_write = self._on_socket_register_write_sync
|
|
422
409
|
await self._loop.run_in_executor(None, self._client.reconnect)
|
|
423
|
-
except OSError:
|
|
424
|
-
|
|
410
|
+
except (OSError, TimeoutError) as exc:
|
|
411
|
+
# Expected transient failures — panel offline, DNS miss, socket
|
|
412
|
+
# timeout, refused connection. The exception type and errno are
|
|
413
|
+
# the full diagnostic; paho/stdlib stack frames add no signal.
|
|
414
|
+
# ssl.SSLError is an OSError subclass and falls in here too;
|
|
415
|
+
# SSL misconfiguration would have failed at setup, so a
|
|
416
|
+
# reconnect-time SSL error is handled as a transient failure.
|
|
417
|
+
_LOGGER.warning("Reconnect failed (%s), retrying in %ss", exc, delay)
|
|
418
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
419
|
+
# Unknown territory — keep the traceback so support tickets
|
|
420
|
+
# are actionable. Never let the reconnect loop die.
|
|
421
|
+
_LOGGER.warning("Reconnect failed, retrying in %ss", delay, exc_info=True)
|
|
425
422
|
finally:
|
|
426
423
|
if self._client is not None:
|
|
427
424
|
self._client.on_socket_open = self._async_on_socket_open
|
|
@@ -31,6 +31,11 @@ from .const import (
|
|
|
31
31
|
|
|
32
32
|
_LOGGER = logging.getLogger(__name__)
|
|
33
33
|
|
|
34
|
+
# Threshold below which grid power is considered "not exchanging" when no
|
|
35
|
+
# authoritative bess/grid-state is available. Real lugs readings never land
|
|
36
|
+
# exactly on 0.0; 1 W is well below sensor noise.
|
|
37
|
+
_GRID_POWER_EPSILON_W = 1.0
|
|
38
|
+
|
|
34
39
|
|
|
35
40
|
def _parse_bool(value: str) -> bool:
|
|
36
41
|
"""Parse a Homie boolean string."""
|
|
@@ -230,9 +235,10 @@ class HomieDeviceConsumer:
|
|
|
230
235
|
"""Build a circuit snapshot from accumulated properties."""
|
|
231
236
|
circuit_id = normalize_circuit_id(node_id)
|
|
232
237
|
|
|
233
|
-
# active-power is in watts; negate so positive = consumption
|
|
238
|
+
# active-power is in watts; negate so positive = consumption.
|
|
239
|
+
# Guard against -0.0 creeping in when raw_power_w is 0.0.
|
|
234
240
|
raw_power_w = _parse_float(self._acc.get_prop(node_id, "active-power"))
|
|
235
|
-
instant_power_w =
|
|
241
|
+
instant_power_w = 0.0 if raw_power_w == 0.0 else -raw_power_w
|
|
236
242
|
|
|
237
243
|
# Energy: exported-energy = consumption (panel exports TO circuit)
|
|
238
244
|
consumed_wh = _parse_float(self._acc.get_prop(node_id, "exported-energy"))
|
|
@@ -387,7 +393,9 @@ class HomieDeviceConsumer:
|
|
|
387
393
|
return "DSM_ON_GRID"
|
|
388
394
|
|
|
389
395
|
if dps in ("BATTERY", "PV", "GENERATOR"):
|
|
390
|
-
grid_exchanging = grid_power
|
|
396
|
+
grid_exchanging = abs(grid_power) > _GRID_POWER_EPSILON_W or (
|
|
397
|
+
power_flow_grid is not None and abs(power_flow_grid) > _GRID_POWER_EPSILON_W
|
|
398
|
+
)
|
|
391
399
|
return "DSM_ON_GRID" if grid_exchanging else "DSM_OFF_GRID"
|
|
392
400
|
|
|
393
401
|
return "UNKNOWN"
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Protocol interfaces for SPAN Panel API transports.
|
|
2
2
|
|
|
3
|
-
Defines structural subtyping contracts (PEP 544) that
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Defines structural subtyping contracts (PEP 544) that the MQTT transport
|
|
4
|
+
implements. The integration codes against these protocols — never against
|
|
5
|
+
transport-specific classes.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import json
|
|
7
|
-
import tempfile
|
|
8
7
|
from collections.abc import AsyncGenerator
|
|
9
8
|
from unittest.mock import MagicMock, patch
|
|
10
9
|
|
|
@@ -109,14 +108,9 @@ async def mqtt_client_mock() -> AsyncGenerator[MagicMock, None]:
|
|
|
109
108
|
with (
|
|
110
109
|
patch("span_panel_api.mqtt.connection.AsyncMQTTClient") as cls,
|
|
111
110
|
patch("span_panel_api.mqtt.connection.download_ca_cert", return_value="FAKE-PEM"),
|
|
112
|
-
patch("span_panel_api.mqtt.connection.
|
|
111
|
+
patch("span_panel_api.mqtt.connection._build_ssl_context", return_value=MagicMock()),
|
|
113
112
|
patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_MOCK_SCHEMA),
|
|
114
113
|
):
|
|
115
|
-
# Make tempfile return a mock file object
|
|
116
|
-
mock_tmp = MagicMock()
|
|
117
|
-
mock_tmp.name = f"{tempfile.gettempdir()}/fake_ca.pem"
|
|
118
|
-
mock_tempfile.NamedTemporaryFile.return_value = mock_tmp
|
|
119
|
-
|
|
120
114
|
mock_client = cls.return_value
|
|
121
115
|
mock_client.connect.side_effect = _connect
|
|
122
116
|
mock_client.reconnect.side_effect = _reconnect
|
|
@@ -668,7 +668,7 @@ class TestGetFqdn:
|
|
|
668
668
|
assert result == "panel.example.com"
|
|
669
669
|
|
|
670
670
|
@pytest.mark.asyncio
|
|
671
|
-
async def
|
|
671
|
+
async def test_get_fqdn_not_configured_returns_none(self):
|
|
672
672
|
mock_response = _mock_response(404)
|
|
673
673
|
with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls:
|
|
674
674
|
mock_client = AsyncMock()
|
|
@@ -679,6 +679,34 @@ class TestGetFqdn:
|
|
|
679
679
|
|
|
680
680
|
result = await get_fqdn("192.168.65.70", "jwt-token")
|
|
681
681
|
|
|
682
|
+
assert result is None
|
|
683
|
+
|
|
684
|
+
@pytest.mark.asyncio
|
|
685
|
+
async def test_get_fqdn_missing_field_returns_none(self):
|
|
686
|
+
mock_response = _mock_response(200, {})
|
|
687
|
+
with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls:
|
|
688
|
+
mock_client = AsyncMock()
|
|
689
|
+
mock_client.get.return_value = mock_response
|
|
690
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
691
|
+
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
692
|
+
mock_client_cls.return_value = mock_client
|
|
693
|
+
|
|
694
|
+
result = await get_fqdn("192.168.65.70", "jwt-token")
|
|
695
|
+
|
|
696
|
+
assert result is None
|
|
697
|
+
|
|
698
|
+
@pytest.mark.asyncio
|
|
699
|
+
async def test_get_fqdn_empty_string_preserved(self):
|
|
700
|
+
mock_response = _mock_response(200, {"ebusTlsFqdn": ""})
|
|
701
|
+
with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls:
|
|
702
|
+
mock_client = AsyncMock()
|
|
703
|
+
mock_client.get.return_value = mock_response
|
|
704
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
705
|
+
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
706
|
+
mock_client_cls.return_value = mock_client
|
|
707
|
+
|
|
708
|
+
result = await get_fqdn("192.168.65.70", "jwt-token")
|
|
709
|
+
|
|
682
710
|
assert result == ""
|
|
683
711
|
|
|
684
712
|
@pytest.mark.asyncio
|
|
@@ -176,7 +176,7 @@ class TestConnectionEventDispatch:
|
|
|
176
176
|
client.register_connection_callback(bad)
|
|
177
177
|
client.register_connection_callback(good_calls.append)
|
|
178
178
|
|
|
179
|
-
with caplog.at_level(logging.
|
|
179
|
+
with caplog.at_level(logging.WARNING):
|
|
180
180
|
client._on_connection_change(True)
|
|
181
181
|
|
|
182
182
|
assert good_calls == [True]
|