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.
Files changed (83) hide show
  1. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.github/workflows/dependabot-auto-approve.yml +1 -1
  2. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.github/workflows/dependabot-auto-merge.yml +1 -1
  3. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/CHANGELOG.md +23 -0
  4. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/PKG-INFO +1 -1
  5. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/pyproject.toml +1 -2
  6. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/factory.py +7 -3
  7. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/client.py +34 -0
  8. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/connection.py +188 -29
  9. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/const.py +6 -0
  10. span_panel_api-2.6.4/tests/test_mqtt_connect_flow.py +957 -0
  11. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/uv.lock +4 -4
  12. span_panel_api-2.6.2/tests/test_mqtt_connect_flow.py +0 -419
  13. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.codefactor +0 -0
  14. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.codefactor.yml +0 -0
  15. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.deps-installed +0 -0
  16. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  17. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  18. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.github/dependabot.yml +0 -0
  19. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.github/workflows/ci.yml +0 -0
  20. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.github/workflows/release.yml +0 -0
  21. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.gitignore +0 -0
  22. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.markdownlint-cli2.jsonc +0 -0
  23. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.markdownlint.json +0 -0
  24. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.pre-commit-config.yaml +0 -0
  25. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.prettierrc.json +0 -0
  26. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.vscode/extensions.json +0 -0
  27. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/.vscode/tasks.json +0 -0
  28. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/DEVELOPMENT.md +0 -0
  29. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/LICENSE +0 -0
  30. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/README.md +0 -0
  31. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/SECURITY.md +0 -0
  32. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/conftest.py +0 -0
  33. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/developer_attribute_readme.md +0 -0
  34. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/openapi.json +0 -0
  35. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/pytest.ini +0 -0
  36. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/pytest_output.log +0 -0
  37. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/scripts/__init__.py +0 -0
  38. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/scripts/coverage.py +0 -0
  39. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/scripts/format.sh +0 -0
  40. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/scripts/format_markdown.py +0 -0
  41. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/scripts/test_live_auth.py +0 -0
  42. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/setup-hooks.sh +0 -0
  43. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/__init__.py +0 -0
  44. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/_http.py +0 -0
  45. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/auth.py +0 -0
  46. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/const.py +0 -0
  47. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/detection.py +0 -0
  48. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/exceptions.py +0 -0
  49. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/models.py +0 -0
  50. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/__init__.py +0 -0
  51. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/accumulator.py +0 -0
  52. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/async_client.py +0 -0
  53. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/field_metadata.py +0 -0
  54. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/homie.py +0 -0
  55. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/mqtt/models.py +0 -0
  56. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/phase_validation.py +0 -0
  57. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/protocol.py +0 -0
  58. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/src/span_panel_api/py.typed +0 -0
  59. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/conftest.py +0 -0
  60. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  61. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  62. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  63. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/fixtures/v2/README.md +0 -0
  64. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/fixtures/v2/homie_schema.json +0 -0
  65. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/fixtures/v2/status.json +0 -0
  66. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/simulation_fixtures/circuits.response.txt +0 -0
  67. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/simulation_fixtures/panel.response.txt +0 -0
  68. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/simulation_fixtures/soe.response.txt +0 -0
  69. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/simulation_fixtures/status.response.txt +0 -0
  70. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_accumulator.py +0 -0
  71. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_async_mqtt_client.py +0 -0
  72. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_auth_and_homie_helpers.py +0 -0
  73. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_detection_auth.py +0 -0
  74. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_exceptions.py +0 -0
  75. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_field_metadata.py +0 -0
  76. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_mqtt_bridge.py +0 -0
  77. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_mqtt_client_connection.py +0 -0
  78. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_mqtt_debounce.py +0 -0
  79. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_mqtt_homie.py +0 -0
  80. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_phase_validation_configs.py +0 -0
  81. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_phase_validation_errors.py +0 -0
  82. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_protocol_conformance.py +0 -0
  83. {span_panel_api-2.6.2 → span_panel_api-2.6.4}/tests/test_protocol_models.py +0 -0
@@ -15,7 +15,7 @@ jobs:
15
15
  steps:
16
16
  - name: Dependabot metadata
17
17
  id: metadata
18
- uses: dependabot/fetch-metadata@v3.0.0
18
+ uses: dependabot/fetch-metadata@v3.1.0
19
19
  with:
20
20
  github-token: "${{ secrets.GITHUB_TOKEN }}"
21
21
 
@@ -15,7 +15,7 @@ jobs:
15
15
  steps:
16
16
  - name: Dependabot metadata
17
17
  id: metadata
18
- uses: dependabot/fetch-metadata@v3.0.0
18
+ uses: dependabot/fetch-metadata@v3.1.0
19
19
  with:
20
20
  github-token: "${{ secrets.GITHUB_TOKEN }}"
21
21
 
@@ -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.2
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.2"
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
- return SpanMqttClient(host, serial_number, mqtt_config)
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 = 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)
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, 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.
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"