span-panel-api 2.6.2__tar.gz → 2.6.4__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.2 → span_panel_api-2.6.4}/.github/workflows/dependabot-auto-approve.yml +1 -1
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.github/workflows/dependabot-auto-merge.yml +1 -1
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/CHANGELOG.md +23 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/PKG-INFO +1 -1
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/pyproject.toml +1 -2
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/factory.py +7 -3
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/client.py +34 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/connection.py +188 -29
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/const.py +6 -0
- span_panel_api-2.6.4/tests/test_mqtt_connect_flow.py +957 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/uv.lock +4 -4
- span_panel_api-2.6.2/tests/test_mqtt_connect_flow.py +0 -419
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.codefactor +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.codefactor.yml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.deps-installed +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.github/dependabot.yml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.github/workflows/ci.yml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.github/workflows/release.yml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.gitignore +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.markdownlint-cli2.jsonc +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.markdownlint.json +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.pre-commit-config.yaml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.prettierrc.json +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.vscode/extensions.json +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.vscode/tasks.json +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/DEVELOPMENT.md +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/LICENSE +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/README.md +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/SECURITY.md +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/conftest.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/developer_attribute_readme.md +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/openapi.json +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/pytest.ini +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/pytest_output.log +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/scripts/__init__.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/scripts/coverage.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/scripts/format.sh +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/scripts/format_markdown.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/scripts/test_live_auth.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/setup-hooks.sh +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/__init__.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/auth.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/const.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/models.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/accumulator.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/field_metadata.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/homie.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/conftest.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/fixtures/v2/homie_schema.json +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_accumulator.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_detection_auth.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_exceptions.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_field_metadata.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_mqtt_homie.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_protocol_models.py +0 -0
|
@@ -4,6 +4,29 @@ 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.4] - 05/2026
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **MQTT reconnect now self-heals after persistent failure** — `AsyncMqttBridge._reconnect_loop` rebuilds the paho client from scratch (re-fetching the panel CA, constructing a fresh client, resetting the Homie accumulator) after
|
|
12
|
+
`MQTT_FULL_REBUILD_AFTER_FAILURES` (3) consecutive failures, or immediately on any `ssl.SSLError`. The previous behavior pinned the panel's CA certificate into the paho client once at `connect()` time and re-used it across all reconnect attempts; if the
|
|
13
|
+
panel rotated its private CA — most plausibly during a firmware upgrade — every subsequent reconnect raised `ssl.SSLCertVerificationError` (caught by the broad `OSError` clause and silently retried) and the bridge could not recover without a config-entry
|
|
14
|
+
reload. The rebuild mirrors what a manual reload does without going through HA's `config_entry` teardown, so entities stay registered and the integration's grace-period logic continues to apply unchanged. The threshold-cadence design (counter reset on
|
|
15
|
+
every rebuild attempt, success or fail) keeps the recovery path active throughout extended outages — multi-day disconnections recover whenever the panel becomes usable again, including if the CA rotates a second time mid-outage. See
|
|
16
|
+
`SpanPanel_Docs/span-panel-api/2026-05-17-mqtt-ca-refresh-on-reconnect-design.md` for the full design.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **`AsyncMqttBridge._rebuild_client()`** — internal recovery method invoked by the reconnect loop on persistent failure. Re-fetches the panel CA via `download_ca_cert()`, builds a fresh paho client via the new `_make_paho_client()` factory, fires the
|
|
21
|
+
optional pre-rebuild callback so consumers can reset their own state, tears down the old client, and submits the initial connect via the executor. Restores the previous client on any failure.
|
|
22
|
+
- **`AsyncMqttBridge.set_pre_rebuild_callback()`** — internal API for `SpanMqttClient` to register a hook that fires before each rebuild. Used to reset the Homie accumulator so retained messages on the new subscription start from a clean slate.
|
|
23
|
+
- **`MQTT_FULL_REBUILD_AFTER_FAILURES`** constant in `mqtt/const.py`.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **`SpanPanelAPIError` now in the bridge's CA-fetch exception list** — a `download_ca_cert()` failure during rebuild (e.g. panel returns HTTP 502 mid-outage) is caught, logged at WARNING, and the loop continues retrying with the previous client instead of
|
|
28
|
+
letting the reconnect task die.
|
|
29
|
+
|
|
7
30
|
## [2.6.2] - 04/2026
|
|
8
31
|
|
|
9
32
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 2.6.
|
|
3
|
+
Version: 2.6.4
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "span-panel-api"
|
|
3
|
-
version = "2.6.
|
|
3
|
+
version = "2.6.4"
|
|
4
4
|
description = "A client library for SPAN Panel API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "SpanPanel"}
|
|
@@ -129,7 +129,6 @@ omit = [
|
|
|
129
129
|
"*/tests/*",
|
|
130
130
|
"*/.venv/*",
|
|
131
131
|
"*/venv/*",
|
|
132
|
-
"src/span_panel_api/mqtt/connection.py",
|
|
133
132
|
]
|
|
134
133
|
|
|
135
134
|
[tool.coverage.report]
|
|
@@ -24,6 +24,7 @@ async def create_span_client(
|
|
|
24
24
|
passphrase: str | None = None,
|
|
25
25
|
mqtt_config: MqttClientConfig | None = None,
|
|
26
26
|
serial_number: str | None = None,
|
|
27
|
+
port: int = 80,
|
|
27
28
|
) -> SpanMqttClient:
|
|
28
29
|
"""Create a SPAN Panel MQTT client.
|
|
29
30
|
|
|
@@ -32,6 +33,7 @@ async def create_span_client(
|
|
|
32
33
|
passphrase: Panel passphrase for v2 registration.
|
|
33
34
|
mqtt_config: Pre-built MQTT broker configuration.
|
|
34
35
|
serial_number: Panel serial number (extracted from detection/registration if omitted).
|
|
36
|
+
port: HTTP port of the panel bootstrap API used for registration and detection.
|
|
35
37
|
|
|
36
38
|
Returns:
|
|
37
39
|
A connected-ready SpanMqttClient instance.
|
|
@@ -45,7 +47,7 @@ async def create_span_client(
|
|
|
45
47
|
if mqtt_config is None:
|
|
46
48
|
if passphrase is None:
|
|
47
49
|
raise SpanPanelAuthError("Neither mqtt_config nor passphrase provided")
|
|
48
|
-
auth_response = await register_v2(host, _V2_CLIENT_NAME, passphrase)
|
|
50
|
+
auth_response = await register_v2(host, _V2_CLIENT_NAME, passphrase, port=port)
|
|
49
51
|
mqtt_config = MqttClientConfig(
|
|
50
52
|
broker_host=auth_response.ebus_broker_host,
|
|
51
53
|
username=auth_response.ebus_broker_username,
|
|
@@ -59,11 +61,13 @@ async def create_span_client(
|
|
|
59
61
|
|
|
60
62
|
if serial_number is None:
|
|
61
63
|
# Try to detect from panel status
|
|
62
|
-
result = await detect_api_version(host)
|
|
64
|
+
result = await detect_api_version(host, port=port)
|
|
63
65
|
if result.status_info is not None:
|
|
64
66
|
serial_number = result.status_info.serial_number
|
|
65
67
|
|
|
66
68
|
if serial_number is None:
|
|
67
69
|
raise SpanPanelAuthError("serial_number is required for MQTT transport but could not be determined")
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
client = SpanMqttClient(host, serial_number, mqtt_config, panel_http_port=port)
|
|
72
|
+
await client.connect()
|
|
73
|
+
return client
|
|
@@ -63,6 +63,10 @@ class SpanMqttClient:
|
|
|
63
63
|
self._field_metadata: dict[str, FieldMetadata] | None = None
|
|
64
64
|
self._schema_hash: str | None = None
|
|
65
65
|
self._previous_schema_types: HomieSchemaTypes | None = None
|
|
66
|
+
# Cached at connect() so the pre-rebuild hook can reconstruct the
|
|
67
|
+
# Homie accumulator with the same panel size after a transport-level
|
|
68
|
+
# rebuild. Schema cannot change within a session, so caching is safe.
|
|
69
|
+
self._panel_size: int | None = None
|
|
66
70
|
|
|
67
71
|
def _require_homie(self) -> HomieDeviceConsumer:
|
|
68
72
|
"""Return the HomieDeviceConsumer, raising if not yet connected."""
|
|
@@ -115,6 +119,7 @@ class SpanMqttClient:
|
|
|
115
119
|
|
|
116
120
|
# Fetch schema to determine panel size and build field metadata
|
|
117
121
|
schema = await get_homie_schema(self._host, port=self._panel_http_port)
|
|
122
|
+
self._panel_size = schema.panel_size
|
|
118
123
|
self._accumulator = HomiePropertyAccumulator(self._serial_number)
|
|
119
124
|
self._homie = HomieDeviceConsumer(self._accumulator, schema.panel_size)
|
|
120
125
|
|
|
@@ -157,6 +162,11 @@ class SpanMqttClient:
|
|
|
157
162
|
# Wire message handler
|
|
158
163
|
self._bridge.set_message_callback(self._on_message)
|
|
159
164
|
self._bridge.set_connection_callback(self._on_connection_change)
|
|
165
|
+
# Pre-rebuild hook: reset Homie accumulator before the bridge swaps
|
|
166
|
+
# paho clients, so retained messages on the new subscription start
|
|
167
|
+
# from a clean slate (no stale `$state=disconnected` cached from
|
|
168
|
+
# the original outage).
|
|
169
|
+
self._bridge.set_pre_rebuild_callback(self._on_pre_rebuild)
|
|
160
170
|
|
|
161
171
|
# Connect to broker
|
|
162
172
|
_LOGGER.debug("MQTT: Connecting to broker...")
|
|
@@ -369,6 +379,30 @@ class SpanMqttClient:
|
|
|
369
379
|
except Exception: # pylint: disable=broad-exception-caught
|
|
370
380
|
_LOGGER.warning("Connection callback raised", exc_info=True)
|
|
371
381
|
|
|
382
|
+
def _on_pre_rebuild(self) -> None:
|
|
383
|
+
"""Reset Homie accumulator state before the bridge rebuilds its paho client.
|
|
384
|
+
|
|
385
|
+
Called synchronously from the bridge's `_rebuild_client` before the
|
|
386
|
+
old paho client is torn down and the new one is wired up. Discards
|
|
387
|
+
any stale `$state=disconnected` cached during the outage so the
|
|
388
|
+
new subscription's retained messages repopulate from a clean slate.
|
|
389
|
+
|
|
390
|
+
Schema-derived state (`_field_metadata`, `_schema_hash`,
|
|
391
|
+
`_previous_schema_types`) is intentionally preserved — the Homie
|
|
392
|
+
schema cannot change within a session, so the cache remains valid
|
|
393
|
+
and a refetch would just add cost. If the panel reboots and the
|
|
394
|
+
schema actually changed, the existing drift-detection log fires on
|
|
395
|
+
the next session's `connect()`.
|
|
396
|
+
"""
|
|
397
|
+
if self._panel_size is None:
|
|
398
|
+
# Pre-rebuild fired before connect() cached the panel size.
|
|
399
|
+
# Treat as a no-op — there is no accumulator state to reset
|
|
400
|
+
# because connect() never completed.
|
|
401
|
+
return
|
|
402
|
+
_LOGGER.debug("Pre-rebuild — resetting Homie accumulator")
|
|
403
|
+
self._accumulator = HomiePropertyAccumulator(self._serial_number)
|
|
404
|
+
self._homie = HomieDeviceConsumer(self._accumulator, self._panel_size)
|
|
405
|
+
|
|
372
406
|
async def _wait_for_circuit_names(self, timeout: float) -> None:
|
|
373
407
|
"""Wait for all circuit-like nodes to have a ``name`` property.
|
|
374
408
|
|
|
@@ -23,10 +23,11 @@ from paho.mqtt.properties import Properties
|
|
|
23
23
|
from paho.mqtt.reasoncodes import ReasonCode
|
|
24
24
|
|
|
25
25
|
from ..auth import download_ca_cert
|
|
26
|
-
from ..exceptions import SpanPanelConnectionError, SpanPanelTimeoutError
|
|
26
|
+
from ..exceptions import SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError
|
|
27
27
|
from .async_client import AsyncMQTTClient
|
|
28
28
|
from .const import (
|
|
29
29
|
MQTT_CONNECT_TIMEOUT_S,
|
|
30
|
+
MQTT_FULL_REBUILD_AFTER_FAILURES,
|
|
30
31
|
MQTT_KEEPALIVE_S,
|
|
31
32
|
MQTT_RECONNECT_BACKOFF_MULTIPLIER,
|
|
32
33
|
MQTT_RECONNECT_MAX_DELAY_S,
|
|
@@ -97,6 +98,7 @@ class AsyncMqttBridge:
|
|
|
97
98
|
|
|
98
99
|
self._message_callback: Callable[[str, str], None] | None = None
|
|
99
100
|
self._connection_callback: Callable[[bool], None] | None = None
|
|
101
|
+
self._pre_rebuild_callback: Callable[[], None] | None = None
|
|
100
102
|
|
|
101
103
|
def is_connected(self) -> bool:
|
|
102
104
|
"""Return whether the MQTT client is currently connected."""
|
|
@@ -110,6 +112,44 @@ class AsyncMqttBridge:
|
|
|
110
112
|
"""Set callback for connection state changes: callback(is_connected)."""
|
|
111
113
|
self._connection_callback = callback
|
|
112
114
|
|
|
115
|
+
def set_pre_rebuild_callback(self, callback: Callable[[], None]) -> None:
|
|
116
|
+
"""Set callback invoked just before the bridge rebuilds its paho client.
|
|
117
|
+
|
|
118
|
+
Used by SpanMqttClient to reset its Homie accumulator so any stale
|
|
119
|
+
in-memory state (e.g. cached `$state=disconnected`) is discarded
|
|
120
|
+
before the new client subscribes and retained messages flow in.
|
|
121
|
+
|
|
122
|
+
Callback runs synchronously inside `_rebuild_client` before the old
|
|
123
|
+
paho client is torn down. Exceptions are caught and logged so a
|
|
124
|
+
misbehaving subscriber cannot prevent the rebuild.
|
|
125
|
+
"""
|
|
126
|
+
self._pre_rebuild_callback = callback
|
|
127
|
+
|
|
128
|
+
def _make_paho_client(self, ssl_context: ssl.SSLContext | None) -> AsyncMQTTClient:
|
|
129
|
+
"""Build and wire a fresh paho client.
|
|
130
|
+
|
|
131
|
+
Shared by connect() (initial connect) and _rebuild_client() (in-loop
|
|
132
|
+
rebuild). Keeps the callback wiring in one place so a rebuild is
|
|
133
|
+
provably symmetric with initial connect.
|
|
134
|
+
"""
|
|
135
|
+
client = AsyncMQTTClient(
|
|
136
|
+
callback_api_version=CallbackAPIVersion.VERSION2,
|
|
137
|
+
transport=self._transport,
|
|
138
|
+
reconnect_on_failure=False,
|
|
139
|
+
)
|
|
140
|
+
client.setup()
|
|
141
|
+
client.username_pw_set(self._username, self._password)
|
|
142
|
+
# Wire socket callbacks (async versions by default)
|
|
143
|
+
client.on_socket_close = self._async_on_socket_close
|
|
144
|
+
client.on_socket_unregister_write = self._async_on_socket_unregister_write
|
|
145
|
+
# Wire MQTT callbacks (run directly on event loop — no thread dispatch)
|
|
146
|
+
client.on_connect = self._on_connect
|
|
147
|
+
client.on_disconnect = self._on_disconnect
|
|
148
|
+
client.on_message = self._on_message
|
|
149
|
+
if ssl_context is not None:
|
|
150
|
+
client.tls_set_context(ssl_context)
|
|
151
|
+
return client
|
|
152
|
+
|
|
113
153
|
async def connect(self) -> None:
|
|
114
154
|
"""Connect to the MQTT broker.
|
|
115
155
|
|
|
@@ -142,26 +182,7 @@ class AsyncMqttBridge:
|
|
|
142
182
|
except (ssl.SSLError, ValueError) as exc:
|
|
143
183
|
raise SpanPanelConnectionError(f"Failed to build SSL context for {self._panel_host}") from exc
|
|
144
184
|
|
|
145
|
-
self._client =
|
|
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)
|
|
185
|
+
self._client = self._make_paho_client(ssl_context)
|
|
165
186
|
|
|
166
187
|
# Connect in executor (blocking: DNS, TCP, TLS handshake).
|
|
167
188
|
# During executor connect, socket callbacks bridge to the event
|
|
@@ -395,9 +416,132 @@ class AsyncMqttBridge:
|
|
|
395
416
|
|
|
396
417
|
# -- Reconnection -------------------------------------------------------
|
|
397
418
|
|
|
419
|
+
async def _rebuild_client(self) -> bool:
|
|
420
|
+
"""Tear down the paho client and rebuild it from scratch.
|
|
421
|
+
|
|
422
|
+
Replicates what a manual integration reload does without going
|
|
423
|
+
through HA's config_entry teardown. Re-fetches the panel CA,
|
|
424
|
+
builds a fresh paho client with the same callbacks, fires the
|
|
425
|
+
pre-rebuild callback so SpanMqttClient can reset its accumulator,
|
|
426
|
+
and submits an initial connect via the executor.
|
|
427
|
+
|
|
428
|
+
Returns True when the new client was built and the initial connect
|
|
429
|
+
was successfully submitted. Returns False on any failure (panel
|
|
430
|
+
unreachable, CA endpoint down, executor connect raised) — the
|
|
431
|
+
previous client is left in place and the reconnect loop continues
|
|
432
|
+
retrying with it.
|
|
433
|
+
|
|
434
|
+
Recovery target: CA rotation (firmware upgrade), stale paho client
|
|
435
|
+
internal state, stuck Homie accumulator. See the design doc at
|
|
436
|
+
SpanPanel_Docs/span-panel-api/2026-05-17-mqtt-ca-refresh-on-reconnect-design.md.
|
|
437
|
+
"""
|
|
438
|
+
if self._loop is None:
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
old_client = self._client
|
|
442
|
+
|
|
443
|
+
# Fetch fresh CA (TLS bridges only). Failure is non-fatal — old
|
|
444
|
+
# client stays in place and the loop retries on the next tick.
|
|
445
|
+
ssl_context: ssl.SSLContext | None = None
|
|
446
|
+
if self._use_tls:
|
|
447
|
+
try:
|
|
448
|
+
ca_pem = await download_ca_cert(self._panel_host, port=self._panel_http_port)
|
|
449
|
+
ssl_context = _build_ssl_context(ca_pem)
|
|
450
|
+
except (
|
|
451
|
+
OSError,
|
|
452
|
+
SpanPanelConnectionError,
|
|
453
|
+
SpanPanelTimeoutError,
|
|
454
|
+
SpanPanelAPIError,
|
|
455
|
+
ssl.SSLError,
|
|
456
|
+
ValueError,
|
|
457
|
+
) as exc:
|
|
458
|
+
_LOGGER.warning("Client rebuild — CA fetch failed: %s", exc)
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
# Fire pre-rebuild hook before we touch any state. SpanMqttClient
|
|
462
|
+
# uses this to discard its stale Homie accumulator so retained
|
|
463
|
+
# messages on the new subscription start from a clean slate.
|
|
464
|
+
if self._pre_rebuild_callback is not None:
|
|
465
|
+
try:
|
|
466
|
+
self._pre_rebuild_callback()
|
|
467
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
468
|
+
_LOGGER.warning("Pre-rebuild callback raised", exc_info=True)
|
|
469
|
+
|
|
470
|
+
# Everything past this point is wrapped in a broad catch so that
|
|
471
|
+
# unexpected failures (paho construction errors, etc.) cannot kill
|
|
472
|
+
# the reconnect task. The whole point of self-heal is that the
|
|
473
|
+
# loop survives — we never want the recovery path itself to be a
|
|
474
|
+
# source of unrecoverable failure.
|
|
475
|
+
try:
|
|
476
|
+
# Best-effort teardown of the old paho client. paho's disconnect()
|
|
477
|
+
# is synchronous and only severs the socket; the object itself is
|
|
478
|
+
# no longer used.
|
|
479
|
+
if old_client is not None:
|
|
480
|
+
try:
|
|
481
|
+
old_client.disconnect()
|
|
482
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
483
|
+
_LOGGER.debug("Old paho client disconnect raised", exc_info=True)
|
|
484
|
+
|
|
485
|
+
# Build fresh client and assign it BEFORE the executor await so
|
|
486
|
+
# that a CONNACK arriving during the await sees the right client.
|
|
487
|
+
# Without this, the _on_connect → re-subscribe path would route
|
|
488
|
+
# through self._client which would still be the (disconnected)
|
|
489
|
+
# old_client, and the new client's subscription would never run.
|
|
490
|
+
new_client = self._make_paho_client(ssl_context)
|
|
491
|
+
new_client.on_socket_open = self._on_socket_open_sync
|
|
492
|
+
new_client.on_socket_register_write = self._on_socket_register_write_sync
|
|
493
|
+
self._client = new_client
|
|
494
|
+
|
|
495
|
+
def _blocking_connect() -> None:
|
|
496
|
+
new_client.connect(
|
|
497
|
+
host=self._host,
|
|
498
|
+
port=self._port,
|
|
499
|
+
keepalive=MQTT_KEEPALIVE_S,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
await self._loop.run_in_executor(None, _blocking_connect)
|
|
504
|
+
except asyncio.CancelledError:
|
|
505
|
+
# Bridge teardown or _on_connect cancelled us mid-rebuild.
|
|
506
|
+
# Restore the previous client reference so post-teardown
|
|
507
|
+
# state stays consistent, then re-raise — CancelledError
|
|
508
|
+
# must propagate to keep the loop's cancel semantics intact.
|
|
509
|
+
self._client = old_client
|
|
510
|
+
raise
|
|
511
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
512
|
+
_LOGGER.warning("Client rebuild — initial connect failed: %s", exc)
|
|
513
|
+
# Restore the previous client so the loop keeps retrying
|
|
514
|
+
# with what it had. The new client's socket was never opened.
|
|
515
|
+
self._client = old_client
|
|
516
|
+
return False
|
|
517
|
+
finally:
|
|
518
|
+
new_client.on_socket_open = self._async_on_socket_open
|
|
519
|
+
new_client.on_socket_register_write = self._async_on_socket_register_write
|
|
520
|
+
|
|
521
|
+
_LOGGER.info("MQTT client rebuilt for reconnect (TLS=%s)", self._use_tls)
|
|
522
|
+
return True
|
|
523
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
524
|
+
# _make_paho_client raised, or some other unforeseen failure
|
|
525
|
+
# after the CA was fetched. Reconnect loop MUST survive — log
|
|
526
|
+
# with traceback for triage and leave whatever client reference
|
|
527
|
+
# is current in place. CancelledError is BaseException in 3.8+
|
|
528
|
+
# so it bypasses this clause and propagates naturally.
|
|
529
|
+
_LOGGER.warning("Client rebuild — unexpected error: %s", exc, exc_info=True)
|
|
530
|
+
return False
|
|
531
|
+
|
|
398
532
|
async def _reconnect_loop(self) -> None:
|
|
399
|
-
"""Reconnect with exponential backoff.
|
|
533
|
+
"""Reconnect with exponential backoff.
|
|
534
|
+
|
|
535
|
+
Every MQTT_FULL_REBUILD_AFTER_FAILURES consecutive non-SSL failures
|
|
536
|
+
(or on any ssl.SSLError), rebuild the paho client from scratch —
|
|
537
|
+
re-fetching the panel CA and resetting any stale in-memory state.
|
|
538
|
+
Mirrors what a manual integration reload does without going through
|
|
539
|
+
HA's config_entry teardown. The counter resets after every rebuild
|
|
540
|
+
attempt (success or fail) and on `_connected == True`, so the
|
|
541
|
+
cadence holds throughout extended outages.
|
|
542
|
+
"""
|
|
400
543
|
delay = MQTT_RECONNECT_MIN_DELAY_S
|
|
544
|
+
failures_since_rebuild_attempt = 0
|
|
401
545
|
while self._should_reconnect:
|
|
402
546
|
if not self._connected and self._client is not None:
|
|
403
547
|
try:
|
|
@@ -407,22 +551,37 @@ class AsyncMqttBridge:
|
|
|
407
551
|
self._client.on_socket_open = self._on_socket_open_sync
|
|
408
552
|
self._client.on_socket_register_write = self._on_socket_register_write_sync
|
|
409
553
|
await self._loop.run_in_executor(None, self._client.reconnect)
|
|
554
|
+
except ssl.SSLError as exc:
|
|
555
|
+
# TLS verification failure — most likely a CA rotation
|
|
556
|
+
# (firmware upgrade). ssl.SSLError must be caught before
|
|
557
|
+
# OSError because it is an OSError subclass.
|
|
558
|
+
_LOGGER.warning("Reconnect TLS failure (%s), rebuilding client", exc)
|
|
559
|
+
await self._rebuild_client()
|
|
560
|
+
failures_since_rebuild_attempt = 0
|
|
410
561
|
except (OSError, TimeoutError) as exc:
|
|
411
|
-
# Expected transient failures — panel offline, DNS miss,
|
|
412
|
-
# timeout, refused connection.
|
|
413
|
-
#
|
|
414
|
-
#
|
|
415
|
-
#
|
|
416
|
-
|
|
562
|
+
# Expected transient failures — panel offline, DNS miss,
|
|
563
|
+
# socket timeout, refused connection. paho also wraps
|
|
564
|
+
# some TLS handshake errors as generic OSError on the
|
|
565
|
+
# executor connect path; the rebuild after threshold
|
|
566
|
+
# catches those.
|
|
567
|
+
failures_since_rebuild_attempt += 1
|
|
417
568
|
_LOGGER.warning("Reconnect failed (%s), retrying in %ss", exc, delay)
|
|
569
|
+
if failures_since_rebuild_attempt >= MQTT_FULL_REBUILD_AFTER_FAILURES:
|
|
570
|
+
await self._rebuild_client()
|
|
571
|
+
failures_since_rebuild_attempt = 0
|
|
418
572
|
except Exception: # pylint: disable=broad-exception-caught
|
|
419
573
|
# Unknown territory — keep the traceback so support tickets
|
|
420
|
-
# are actionable. Never let the reconnect loop die.
|
|
574
|
+
# are actionable. Never let the reconnect loop die. No
|
|
575
|
+
# rebuild here — unknown errors should not be masked
|
|
576
|
+
# behind a recovery action whose effect we cannot predict.
|
|
577
|
+
failures_since_rebuild_attempt += 1
|
|
421
578
|
_LOGGER.warning("Reconnect failed, retrying in %ss", delay, exc_info=True)
|
|
422
579
|
finally:
|
|
423
580
|
if self._client is not None:
|
|
424
581
|
self._client.on_socket_open = self._async_on_socket_open
|
|
425
582
|
self._client.on_socket_register_write = self._async_on_socket_register_write
|
|
583
|
+
else:
|
|
584
|
+
failures_since_rebuild_attempt = 0
|
|
426
585
|
await asyncio.sleep(delay)
|
|
427
586
|
delay = min(
|
|
428
587
|
delay * MQTT_RECONNECT_BACKOFF_MULTIPLIER,
|
|
@@ -48,6 +48,12 @@ MQTT_RECONNECT_MIN_DELAY_S = 1.0
|
|
|
48
48
|
MQTT_RECONNECT_MAX_DELAY_S = 60.0
|
|
49
49
|
MQTT_RECONNECT_BACKOFF_MULTIPLIER = 2
|
|
50
50
|
|
|
51
|
+
# Every this many consecutive reconnect failures (any reason), rebuild the paho client from scratch
|
|
52
|
+
# and re-fetch the panel CA. Mirrors the recovery effect of a manual integration reload without
|
|
53
|
+
# going through HA's config_entry teardown. Resets after every rebuild attempt so the cadence holds
|
|
54
|
+
# throughout extended outages.
|
|
55
|
+
MQTT_FULL_REBUILD_AFTER_FAILURES = 3
|
|
56
|
+
|
|
51
57
|
# Lugs direction values
|
|
52
58
|
LUGS_UPSTREAM = "UPSTREAM"
|
|
53
59
|
LUGS_DOWNSTREAM = "DOWNSTREAM"
|