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.
Files changed (82) hide show
  1. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/CHANGELOG.md +32 -0
  2. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/PKG-INFO +6 -1
  3. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/README.md +5 -0
  4. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/pyproject.toml +1 -1
  5. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/auth.py +15 -4
  6. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/exceptions.py +0 -3
  7. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/models.py +3 -3
  8. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/async_client.py +24 -0
  9. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/client.py +9 -5
  10. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/connection.py +97 -100
  11. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/homie.py +11 -3
  12. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/protocol.py +3 -3
  13. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/conftest.py +1 -7
  14. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_detection_auth.py +29 -1
  15. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_mqtt_client_connection.py +1 -1
  16. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_mqtt_connect_flow.py +37 -5
  17. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_mqtt_debounce.py +46 -28
  18. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/uv.lock +19 -1
  19. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.codefactor +0 -0
  20. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.codefactor.yml +0 -0
  21. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.deps-installed +0 -0
  22. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  23. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  24. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.github/dependabot.yml +0 -0
  25. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.github/workflows/ci.yml +0 -0
  26. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.github/workflows/dependabot-auto-approve.yml +0 -0
  27. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.github/workflows/dependabot-auto-merge.yml +0 -0
  28. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.github/workflows/release.yml +0 -0
  29. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.gitignore +0 -0
  30. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.markdownlint-cli2.jsonc +0 -0
  31. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.markdownlint.json +0 -0
  32. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.pre-commit-config.yaml +0 -0
  33. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.prettierrc.json +0 -0
  34. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.vscode/extensions.json +0 -0
  35. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/.vscode/tasks.json +0 -0
  36. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/DEVELOPMENT.md +0 -0
  37. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/LICENSE +0 -0
  38. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/SECURITY.md +0 -0
  39. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/conftest.py +0 -0
  40. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/developer_attribute_readme.md +0 -0
  41. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/openapi.json +0 -0
  42. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/pytest.ini +0 -0
  43. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/pytest_output.log +0 -0
  44. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/scripts/__init__.py +0 -0
  45. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/scripts/coverage.py +0 -0
  46. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/scripts/format.sh +0 -0
  47. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/scripts/format_markdown.py +0 -0
  48. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/scripts/test_live_auth.py +0 -0
  49. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/setup-hooks.sh +0 -0
  50. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/__init__.py +0 -0
  51. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/_http.py +0 -0
  52. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/const.py +0 -0
  53. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/detection.py +0 -0
  54. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/factory.py +0 -0
  55. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/__init__.py +0 -0
  56. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/accumulator.py +0 -0
  57. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/const.py +0 -0
  58. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/field_metadata.py +0 -0
  59. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/mqtt/models.py +0 -0
  60. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/phase_validation.py +0 -0
  61. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/src/span_panel_api/py.typed +0 -0
  62. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  63. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  64. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  65. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/fixtures/v2/README.md +0 -0
  66. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/fixtures/v2/homie_schema.json +0 -0
  67. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/fixtures/v2/status.json +0 -0
  68. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/simulation_fixtures/circuits.response.txt +0 -0
  69. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/simulation_fixtures/panel.response.txt +0 -0
  70. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/simulation_fixtures/soe.response.txt +0 -0
  71. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/simulation_fixtures/status.response.txt +0 -0
  72. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_accumulator.py +0 -0
  73. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_async_mqtt_client.py +0 -0
  74. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_auth_and_homie_helpers.py +0 -0
  75. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_exceptions.py +0 -0
  76. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_field_metadata.py +0 -0
  77. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_mqtt_bridge.py +0 -0
  78. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_mqtt_homie.py +0 -0
  79. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_phase_validation_configs.py +0 -0
  80. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_phase_validation_errors.py +0 -0
  81. {span_panel_api-2.6.0 → span_panel_api-2.6.2}/tests/test_protocol_conformance.py +0 -0
  82. {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.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "2.6.0"
3
+ version = "2.6.2"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -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 empty string if none is configured
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
- return _str(data.get("ebusTlsFqdn"))
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 regardless of how it was obtained
4
- (REST polling or MQTT push). Energy and power sign conventions are
5
- normalized at the transport boundary — consumers see a consistent view.
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
- # No debounce — dispatch immediately (backward compat)
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.exception("Connection callback raised")
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 = asyncio.get_event_loop().time() + timeout
381
- while asyncio.get_event_loop().time() < deadline:
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 = no debounce.
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
- ca_pem: str | None = None
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
- try:
129
- self._client = AsyncMQTTClient(
130
- callback_api_version=CallbackAPIVersion.VERSION2,
131
- transport=self._transport,
132
- reconnect_on_failure=False,
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._client.on_socket_open = self._on_socket_open_sync
176
- self._client.on_socket_register_write = self._on_socket_register_write_sync
177
- _LOGGER.debug("BRIDGE: Running TLS+connect in executor to %s:%s", self._host, self._port)
178
- try:
179
- await self._loop.run_in_executor(None, _blocking_tls_and_connect)
180
- except OSError as exc:
181
- raise SpanPanelConnectionError(
182
- f"Cannot connect to MQTT broker at {self._host}:{self._port}: {exc}"
183
- ) from exc
184
- _LOGGER.debug("BRIDGE: Executor connect returned, waiting for CONNACK...")
185
- finally:
186
- # Switch to async-only socket callbacks now that we are
187
- # back on the event loop thread.
188
- self._client.on_socket_open = self._async_on_socket_open
189
- self._client.on_socket_register_write = self._async_on_socket_register_write
190
-
191
- # Wait for CONNACK
192
- try:
193
- await asyncio.wait_for(self._connect_event.wait(), timeout=MQTT_CONNECT_TIMEOUT_S)
194
- except asyncio.TimeoutError as exc:
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
- self._initial_connect_done = True
202
- # Keep cert alive until disconnect — paho may reference it
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
- except Exception:
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
- _LOGGER.debug("MQTT disconnected: %s", reason_code)
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
- _LOGGER.debug("Reconnect failed, retrying in %ss", delay)
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 = -raw_power_w or 0.0
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 != 0.0 or (power_flow_grid is not None and power_flow_grid != 0.0)
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 both MQTT and
4
- simulation transports implement. The integration codes against these
5
- protocols — never against transport-specific classes.
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.tempfile") as mock_tempfile,
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 test_get_fqdn_not_configured_returns_empty(self):
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.ERROR):
179
+ with caplog.at_level(logging.WARNING):
180
180
  client._on_connection_change(True)
181
181
 
182
182
  assert good_calls == [True]