span-panel-api 3.0.0b8__tar.gz → 3.0.0b10__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 (85) hide show
  1. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/CHANGELOG.md +31 -0
  2. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/PKG-INFO +1 -1
  3. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/pyproject.toml +1 -1
  4. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/auth.py +28 -3
  5. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/exceptions.py +7 -1
  6. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/mqtt/client.py +54 -26
  7. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_auth_and_homie_helpers.py +52 -0
  8. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_redispatch_on_reconnect.py +127 -11
  9. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/.gitignore +0 -0
  10. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/LICENSE +0 -0
  11. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/README.md +0 -0
  12. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/__init__.py +0 -0
  13. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/_http.py +0 -0
  14. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/adapters.py +0 -0
  15. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/const.py +0 -0
  16. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/detection.py +0 -0
  17. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/dispatch.py +0 -0
  18. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/factory.py +0 -0
  19. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/models.py +0 -0
  20. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/mqtt/__init__.py +0 -0
  21. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/mqtt/async_client.py +0 -0
  22. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/mqtt/connection.py +0 -0
  23. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/mqtt/const.py +0 -0
  24. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/mqtt/models.py +0 -0
  25. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/phase_validation.py +0 -0
  26. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/protocol.py +0 -0
  27. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/py.typed +0 -0
  28. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/reference_payloads/README.md +0 -0
  29. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/reference_payloads/__init__.py +0 -0
  30. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/reference_payloads/homie_schema.json +0 -0
  31. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/schema_drift.py +0 -0
  32. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/conftest.py +0 -0
  33. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  34. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  35. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  36. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/flat_wire.json +0 -0
  37. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  38. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/v2/README.md +0 -0
  39. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/v2/status.json +0 -0
  40. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/simulation_fixtures/circuits.response.txt +0 -0
  41. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/simulation_fixtures/panel.response.txt +0 -0
  42. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/simulation_fixtures/soe.response.txt +0 -0
  43. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/simulation_fixtures/status.response.txt +0 -0
  44. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_accumulator.py +0 -0
  45. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_adapters_discovery.py +0 -0
  46. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_adopted_control.py +0 -0
  47. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_adoption.py +0 -0
  48. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_async_mqtt_client.py +0 -0
  49. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_catalog_divergence.py +0 -0
  50. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_detection_auth.py +0 -0
  51. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_exceptions.py +0 -0
  52. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_factory_dispatch.py +0 -0
  53. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_field_metadata.py +0 -0
  54. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_live_flat_differential.py +0 -0
  55. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_mqtt_bridge.py +0 -0
  56. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_mqtt_client_connection.py +0 -0
  57. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_mqtt_connect_flow.py +0 -0
  58. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_mqtt_debounce.py +0 -0
  59. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_mqtt_homie.py +0 -0
  60. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_packaging.py +0 -0
  61. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_phase_validation_configs.py +0 -0
  62. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_phase_validation_errors.py +0 -0
  63. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_protocol_conformance.py +0 -0
  64. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_protocol_models.py +0 -0
  65. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_public_api_unchanged.py +0 -0
  66. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_reference_tree_values.py +0 -0
  67. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_generation_cross_check.py +0 -0
  68. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_migration_delta.py +0 -0
  69. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_adapter.py +0 -0
  70. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_against_simulator.py +0 -0
  71. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_charge_limit.py +0 -0
  72. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_circuits.py +0 -0
  73. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_conformance.py +0 -0
  74. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_connection_health.py +0 -0
  75. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_devices.py +0 -0
  76. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_discovery.py +0 -0
  77. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_panel.py +0 -0
  78. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_pcs.py +0 -0
  79. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_service_entrance.py +0 -0
  80. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_shed_forecast.py +0 -0
  81. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_snapshot.py +0 -0
  82. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_transport.py +0 -0
  83. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_provenance.py +0 -0
  84. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_zero_adapter.py +0 -0
  85. {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_shared_http_client.py +0 -0
@@ -4,6 +4,37 @@ 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
+ ## [3.0.0b10]
8
+
9
+ ### Changed
10
+
11
+ - **The wait for a panel to finish rebooting no longer gives up.** It used to stop after a fixed number of attempts, and that bound was wrong twice for the same reason: it was sized against a reboot somebody had measured, and the next reboot was not that
12
+ reboot. Giving up has nothing to recommend it — the only things that start another attempt are the reconnect edge and the panel republishing its data-model version, and a panel that finishes booting after the wait expired produces neither, so running out
13
+ of attempts means stranded until somebody reloads by hand. It now waits as long as the panel takes.
14
+ - **Waiting costs nothing you were relying on.** Energy sensors already hold their last reading through an outage on their own grace period — fifteen minutes by default, configurable — which exists precisely so a gap does not become an `unknown` and a
15
+ statistics spike. That is untouched by how long this waits, and it was the only thing that would have justified a deadline. What is left is one request every thirty seconds to a device on your own network.
16
+ - **The retry interval settles at thirty seconds rather than growing.** Backing off without a ceiling would mean a panel that took a while to return was then ignored for longer than it took. The gap goes 1, 2, 4, 8, 16, 30 and stays there, so once your
17
+ panel is answering it is noticed within half a minute however long the wait has already run.
18
+
19
+ ### Fixed
20
+
21
+ - **Four more ways a booting panel answers now count as "not ready" rather than as a hard failure.** b8 and b9 covered the 502 that a live upgrade produced; review found the fix had covered the observed shape rather than the class. A panel resetting its
22
+ listener mid-request raises `ReadError` or `WriteError`, a proxy that dies mid-request raises `RemoteProtocolError`, and a panel part-way through starting can answer `200` with a truncated or empty body. All four escaped untranslated, skipped the retry
23
+ entirely, and stranded the parser exactly as the 502 did. Transport failures are now `SpanPanelConnectionError` and an unusable body is `SpanPanelServerError`.
24
+ - **The last retry attempt happens after the reboot it is sized for.** The window ended with a sleep that no attempt followed: it read 241 seconds while the final request went out at 211, so a panel ready at 220 was still abandoned. The loop no longer
25
+ sleeps after its final attempt — which also stopped it holding the in-flight guard, and the warning, for a pointless extra backoff — and the last request now lands at 241 seconds. The test asserts that offset instead of summing the sleeps, which was
26
+ restating the implementation's own off-by-one.
27
+ - **The give-up warning no longer promises a recovery that cannot arrive.** It said data would read as missing "until the next reconnect". The triggers are the reconnect edge and the retained `data-model-version` message, and a panel that finishes booting
28
+ produces neither again, so exhausting the window means stuck until a reload. It now says so.
29
+
30
+ ## [3.0.0b9]
31
+
32
+ ### Fixed
33
+
34
+ - **The retry window for a rebooting panel is actually widened this time.** b8 taught the schema fetch to treat a `502` as "not ready yet" but shipped with the old five attempts capped at eight seconds — about twenty-three seconds against a panel observed
35
+ taking four minutes to come back, still answering 502 when the broker returned. The widening was written, lost to a failed edit in the same change, and shipped without it; nothing failed, because catching the 502 and giving up early looks exactly like
36
+ working. Now twelve attempts backing off to thirty seconds, a little over four minutes, and pinned by a test that asserts the total window outlasts the observed reboot rather than checking the constants individually.
37
+
7
38
  ## [3.0.0b8]
8
39
 
9
40
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 3.0.0b8
3
+ Version: 3.0.0b10
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 = "3.0.0b8"
3
+ version = "3.0.0b10"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -188,10 +188,18 @@ async def get_homie_schema(
188
188
  try:
189
189
  async with _get_client(httpx_client, timeout) as client:
190
190
  response = await client.get(url)
191
- except httpx.ConnectError as exc:
192
- raise SpanPanelConnectionError(f"Cannot reach panel at {host}") from exc
193
191
  except httpx.TimeoutException as exc:
194
192
  raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc
193
+ except httpx.TransportError as exc:
194
+ # Every way the connection itself can fail, not just a refused connect:
195
+ # `ReadError` and `WriteError` when a rebooting panel resets mid-request,
196
+ # and `RemoteProtocolError` when its proxy closes without answering --
197
+ # which is exactly what a proxy restarting under load produces. Catching
198
+ # only `ConnectError` meant those escaped this function untranslated,
199
+ # skipped the caller's retry clause entirely, and stranded the parser the
200
+ # same way a 502 used to. `TimeoutException` is itself a `TransportError`,
201
+ # so it has to be caught first.
202
+ raise SpanPanelConnectionError(f"Cannot reach panel at {host}: {exc}") from exc
195
203
 
196
204
  if response.status_code >= 500:
197
205
  # A rebooting panel answers 502 from its front end while the application
@@ -210,7 +218,24 @@ async def get_homie_schema(
210
218
  status_code=response.status_code,
211
219
  )
212
220
 
213
- data: dict[str, object] = response.json()
221
+ try:
222
+ parsed = response.json()
223
+ except ValueError as exc:
224
+ # A panel part-way through starting can answer 200 with a truncated or
225
+ # empty body. Retryable for the same reason a 502 is -- it is "not ready
226
+ # yet" wearing a different status -- and untranslated this had precisely
227
+ # the 502's old character: raised out of the caller's retry loop on the
228
+ # first attempt and left the parser where it was.
229
+ raise SpanPanelServerError(
230
+ f"Panel not ready: {host} answered 200 with a body that is not JSON",
231
+ status_code=response.status_code,
232
+ ) from exc
233
+ if not isinstance(parsed, dict):
234
+ raise SpanPanelServerError(
235
+ f"Panel not ready: {host} answered 200 with {type(parsed).__name__}, not an object",
236
+ status_code=response.status_code,
237
+ )
238
+ data: dict[str, object] = parsed
214
239
 
215
240
  # Extract types — each value is a dict of property definitions
216
241
  raw_types = data.get("types", {})
@@ -30,7 +30,13 @@ class SpanPanelAPIError(SpanPanelError):
30
30
 
31
31
 
32
32
  class SpanPanelServerError(SpanPanelAPIError):
33
- """Server error (500)."""
33
+ """The panel answered, and the answer means "not ready yet".
34
+
35
+ Any 5xx, and a 200 whose body cannot be a schema. Distinct from
36
+ `SpanPanelAPIError` because a caller can retry this and should not retry a
37
+ 4xx, which will not fix itself. A rebooting panel produces these for as long
38
+ as its front end is up and the application behind it is not.
39
+ """
34
40
 
35
41
 
36
42
  class SpanPanelStaleDataError(SpanPanelError):
@@ -48,9 +48,21 @@ _CIRCUIT_NAMES_POLL_INTERVAL_S = 0.25
48
48
  # Re-reading the schema after a suspected generation change. Bounded because the
49
49
  # caller is a fire-and-forget task on a live connection, and generous enough to
50
50
  # outlast a panel that is still binding its HTTP port after a restart.
51
- _REDISPATCH_RETRY_ATTEMPTS = 5
52
51
  _REDISPATCH_RETRY_INITIAL_S = 1.0
53
- _REDISPATCH_RETRY_MAX_S = 8.0
52
+ _REDISPATCH_RETRY_MAX_S = 30.0
53
+ _REDISPATCH_LOG_EVERY = 20
54
+ """How long to wait for the panel's HTTP endpoint after it returns on MQTT.
55
+
56
+ Sized from a live firmware upgrade rather than guessed. The panel dropped MQTT at
57
+ 11:22:07 and the broker was back at 11:26:15 -- four minutes -- and its HTTP
58
+ front end was still answering 502 at that moment. Five attempts capped at 8s
59
+ gives up after about 23 seconds, which is not the same order of magnitude as a
60
+ device that is still booting: catching the 502 buys nothing if the loop stops
61
+ before the panel is ready.
62
+
63
+ Twelve attempts backing off to 30s is a little over four minutes. Each one is a
64
+ single GET, and the panel is the only thing that can end the wait.
65
+ """
54
66
 
55
67
 
56
68
  def _metadata_for_the_log() -> tuple[list[str], str]:
@@ -805,7 +817,7 @@ class SpanMqttClient:
805
817
  return active != observed
806
818
 
807
819
  async def _fetch_schema_with_retry(self) -> V2HomieSchema | None:
808
- """Read the panel's REST schema, allowing for HTTP trailing the broker.
820
+ """Read the panel's REST schema, waiting for HTTP to catch up with the broker.
809
821
 
810
822
  A panel that has just restarted accepts MQTT before it serves HTTP — the
811
823
  broker is listening while the application is still binding its port. The
@@ -814,40 +826,56 @@ class SpanMqttClient:
814
826
  was no further edge to retry on, leaving the wrong parser in place for the
815
827
  rest of the session.
816
828
 
817
- So this waits, briefly and boundedly. Returning None rather than raising
818
- because the caller's job is to reconsider the parser, and being unable to
819
- is not a reason to disturb a connection that is otherwise working.
829
+ **This waits as long as it takes, and that is deliberate.** Every bounded
830
+ version of it has been wrong, twice for the same reason: the bound was
831
+ sized against a reboot somebody had measured, and the next reboot was not
832
+ that reboot. Giving up has no upside to weigh against being wrong. The
833
+ triggers for another attempt are the reconnect edge and the retained
834
+ `data-model-version` message, and a panel that finishes booting after the
835
+ loop gave up produces neither — so exhausting a bound does not mean
836
+ "try again later", it means stranded until somebody reloads by hand.
837
+
838
+ Nor does waiting cost the freshness of anything. Energy sensors already
839
+ hold their last valid reading through an outage on their own grace period,
840
+ which exists precisely so a gap does not become an `unknown` and a
841
+ statistics spike; that mechanism is untouched by how long this waits, and
842
+ it is the thing that would have justified a deadline here. What is left is
843
+ one HTTP GET every thirty seconds to a device on the local network, which
844
+ is less traffic than the ordinary snapshot poll.
845
+
846
+ Ends on success, on cancellation — `close()` cancels this task, so unload
847
+ and shutdown are prompt — or on an error that is not the panel still
848
+ coming up, which is left to raise.
820
849
  """
821
850
  delay = _REDISPATCH_RETRY_INITIAL_S
822
- last: Exception | None = None
823
- for _ in range(_REDISPATCH_RETRY_ATTEMPTS):
851
+ attempts = 0
852
+ while True:
824
853
  try:
825
854
  return await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client)
826
855
  except (
827
856
  SpanPanelConnectionError,
828
857
  SpanPanelTimeoutError,
829
- # The third way HTTP lags the broker, and the one a real upgrade
830
- # actually produced: the panel answers, with 502. Its front end is
831
- # up while the application behind it is still starting, which is
832
- # the ordinary order for a booting device. Omitting this meant the
833
- # first attempt raised straight out of this loop, out of the
834
- # fire-and-forget task that called it, and the parser was never
835
- # swapped -- observed on two Home Assistant instances watching one
836
- # panel through the same upgrade, neither of which recovered
837
- # without a manual reload.
858
+ # The panel answering rather than refusing: a 5xx from its front
859
+ # end while the application behind it starts, or a 200 carrying a
860
+ # body that cannot be a schema. The ordinary shape of a reboot,
861
+ # because a device brings its network stack and proxy up before
862
+ # its application -- and the shape that stranded two live installs
863
+ # when it was not caught here.
838
864
  SpanPanelServerError,
839
865
  ) as exc:
840
- last = exc
866
+ attempts += 1
867
+ if attempts == 1 or attempts % _REDISPATCH_LOG_EVERY == 0:
868
+ # First failure, then occasionally. A panel that never returns
869
+ # would otherwise write a line every thirty seconds forever,
870
+ # and the second line is worth no more than the first.
871
+ _LOGGER.warning(
872
+ "Panel is not serving its schema yet (%s). Attempt %d; still "
873
+ "waiting, and the parser stays as it is until it answers.",
874
+ exc,
875
+ attempts,
876
+ )
841
877
  await asyncio.sleep(delay)
842
878
  delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S)
843
- _LOGGER.warning(
844
- "Could not re-read the panel schema after %d attempts (%s). The active "
845
- "parser is unchanged; if the panel's schema generation did change, its "
846
- "data will read as missing until the next reconnect.",
847
- _REDISPATCH_RETRY_ATTEMPTS,
848
- last,
849
- )
850
- return None
851
879
 
852
880
  async def _redispatch_if_generation_changed(self) -> None:
853
881
  """Swap the parser when the panel comes back as a different schema generation.
@@ -221,3 +221,55 @@ class TestHttpxClientInjectionAuthHelpers:
221
221
 
222
222
  mock_cls.assert_not_called()
223
223
  injected.aclose.assert_not_called()
224
+
225
+
226
+ class TestGetHomieSchemaNotReadyShapes:
227
+ """Every way a booting panel answers that is not a clean 5xx.
228
+
229
+ Each of these used to escape `get_homie_schema` untranslated, skip the
230
+ caller's retry clause entirely, and strand the parser — the same failure the
231
+ 502 produced on a live upgrade, wearing a different exception.
232
+ """
233
+
234
+ @pytest.mark.asyncio
235
+ @pytest.mark.parametrize(
236
+ "failure",
237
+ [
238
+ httpx.ReadError("connection reset"),
239
+ httpx.WriteError("broken pipe"),
240
+ httpx.RemoteProtocolError("server closed connection without sending a response"),
241
+ ],
242
+ ids=["read-reset", "write-reset", "proxy-closed-without-answering"],
243
+ )
244
+ async def test_a_transport_failure_is_a_connection_error(self, failure: Exception) -> None:
245
+ """A panel resetting its listener mid-request, and a proxy dying mid-request.
246
+
247
+ `httpx.TimeoutException` is itself a `TransportError`, so the timeout
248
+ branch has to stay ahead of this one — covered by the timeout test above.
249
+ """
250
+ with patch("span_panel_api._http.httpx.AsyncClient") as cls:
251
+ cls.return_value = _mock_client("get", failure)
252
+ with pytest.raises(SpanPanelConnectionError):
253
+ await get_homie_schema("192.168.1.1")
254
+
255
+ @pytest.mark.asyncio
256
+ @pytest.mark.parametrize("body", ["", "{trunc", "null", "[]"], ids=["empty", "truncated", "null", "list"])
257
+ async def test_a_200_that_cannot_be_a_schema_is_not_ready_rather_than_broken(self, body: str) -> None:
258
+ """A panel part-way through starting can answer 200 with nothing usable.
259
+
260
+ Retryable for the same reason a 502 is: it is "not ready yet" wearing a
261
+ success status. The bounded attempt count makes retrying a genuinely
262
+ broken body cheap.
263
+ """
264
+ response = MagicMock()
265
+ response.status_code = 200
266
+ response.json = MagicMock(side_effect=(lambda: json.loads(body)) if body else ValueError("no content"))
267
+ mock = AsyncMock()
268
+ mock.get = AsyncMock(return_value=response)
269
+ mock.__aenter__ = AsyncMock(return_value=mock)
270
+ mock.__aexit__ = AsyncMock(return_value=False)
271
+
272
+ with patch("span_panel_api._http.httpx.AsyncClient") as cls:
273
+ cls.return_value = mock
274
+ with pytest.raises(SpanPanelServerError):
275
+ await get_homie_schema("192.168.1.1")
@@ -31,7 +31,11 @@ from unittest.mock import patch
31
31
  import pytest
32
32
 
33
33
  from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelServerError
34
- from span_panel_api.mqtt.client import _REDISPATCH_RETRY_ATTEMPTS, SpanMqttClient
34
+ from span_panel_api.mqtt.client import (
35
+ _REDISPATCH_RETRY_INITIAL_S,
36
+ _REDISPATCH_RETRY_MAX_S,
37
+ SpanMqttClient,
38
+ )
35
39
  from span_panel_api.mqtt.models import MqttClientConfig
36
40
 
37
41
  from conftest import SERIAL
@@ -96,7 +100,7 @@ async def _panel_publishes_version(client: SpanMqttClient, version: str | None)
96
100
  # The refetch is scheduled rather than awaited, so the message callback can stay
97
101
  # synchronous. Let the loop drain it.
98
102
  # One turn per retry attempt, plus slack for the task itself.
99
- for _ in range(_REDISPATCH_RETRY_ATTEMPTS + 4):
103
+ for _ in range(24):
100
104
  await asyncio.sleep(0)
101
105
 
102
106
 
@@ -210,13 +214,15 @@ async def test_http_lagging_the_broker_is_retried_not_abandoned() -> None:
210
214
 
211
215
 
212
216
  @pytest.mark.asyncio
213
- async def test_a_panel_that_never_serves_http_leaves_the_parser_alone() -> None:
214
- """Bounded, and non-fatal when the bound is reached.
215
-
216
- MQTT is up or this path would not be running, so tearing the connection down over
217
- an unreachable HTTP endpoint would turn a degraded panel into a dead integration.
218
- A stale parser reports missing data rather than wrong data, because the two
219
- schemas share no topic shape.
217
+ async def test_a_panel_that_is_not_serving_http_yet_leaves_the_parser_alone() -> None:
218
+ """Waiting must not disturb what is already working.
219
+
220
+ MQTT is up or this path would not be running, so tearing the connection down
221
+ over an HTTP endpoint that has not come up would turn a panel that is merely
222
+ booting into a dead integration. The parser stays as it is while the wait
223
+ runs — a stale parser reports missing data rather than wrong data, because
224
+ the two schemas share no topic shape — and the wait keeps going rather than
225
+ giving up, because nothing else will start it again.
220
226
  """
221
227
  client, _ = _client(None)
222
228
  before = client.adapter
@@ -231,8 +237,16 @@ async def test_a_panel_that_never_serves_http_leaves_the_parser_alone() -> None:
231
237
  ):
232
238
  await _panel_publishes_version(client, "1.0")
233
239
 
234
- assert client.adapter is before
235
- assert not client._redispatch_in_flight, "the in-flight guard must clear on failure"
240
+ assert client.adapter is before
241
+ assert client._redispatch_in_flight, (
242
+ "the guard is held for as long as the wait runs, so a second edge does not " "start a competing attempt"
243
+ )
244
+
245
+ # Cancelled directly rather than through `close()`, which this fixture's fake
246
+ # bridge cannot service. That the wait ends on cancellation is covered by
247
+ # `test_the_wait_ends_promptly_when_the_client_is_closed`.
248
+ for task in list(client._background_tasks):
249
+ task.cancel()
236
250
 
237
251
 
238
252
  @pytest.mark.asyncio
@@ -364,3 +378,105 @@ async def test_an_unexpected_failure_leaves_a_usable_message_rather_than_a_bare_
364
378
  assert client.adapter is before
365
379
  assert "Reload the integration" in caplog.text
366
380
  assert "something nobody predicted" in caplog.text
381
+
382
+
383
+ @pytest.mark.asyncio
384
+ async def test_the_backoff_reaches_a_steady_state_rather_than_growing() -> None:
385
+ """Once the panel is up, the wait to notice it must stay short.
386
+
387
+ Doubling without a ceiling would mean a panel that took a while to come back
388
+ was then ignored for longer than it took — minutes between attempts by the
389
+ time it is answering. The interval has to settle, so the worst case between
390
+ the panel being ready and this loop finding out is one interval however long
391
+ the wait has already run.
392
+
393
+ **Observed from the loop, not recomputed.** The first version of this test
394
+ calculated the backoff sequence itself and asserted on its own arithmetic,
395
+ which passes just as happily when the ceiling is removed from the code — the
396
+ same mistake as the window test it replaced. These are the sleeps the real
397
+ function performed.
398
+ """
399
+ client, _ = _client(None)
400
+ slept: list[float] = []
401
+ attempts = 0
402
+
403
+ def _ready_eventually(*_a: object, **_k: object) -> _Schema:
404
+ nonlocal attempts
405
+ attempts += 1
406
+ if attempts < 30:
407
+ raise SpanPanelServerError("Panel not ready: HTTP 502", 502)
408
+ return _Schema("1.0")
409
+
410
+ async def _record(seconds: float) -> None:
411
+ slept.append(seconds)
412
+
413
+ with (
414
+ patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_ready_eventually),
415
+ patch("span_panel_api.mqtt.client.asyncio.sleep", _record),
416
+ ):
417
+ assert await client._fetch_schema_with_retry() is not None
418
+
419
+ assert slept[0] == _REDISPATCH_RETRY_INITIAL_S, "it should start responsive"
420
+ assert max(slept) == _REDISPATCH_RETRY_MAX_S, "and never wait longer than the ceiling"
421
+ assert slept[-1] == _REDISPATCH_RETRY_MAX_S, "settling there rather than continuing to grow"
422
+ assert _REDISPATCH_RETRY_MAX_S <= 30.0, (
423
+ "a steady-state gap longer than half a minute is too long to leave a panel " "that is already answering"
424
+ )
425
+
426
+
427
+ @pytest.mark.asyncio
428
+ async def test_the_wait_does_not_end_on_its_own() -> None:
429
+ """There is no attempt count to exhaust, and that is the point.
430
+
431
+ Every bounded version of this was wrong, twice, for the same reason: the
432
+ bound was sized against a reboot somebody had measured and the next reboot
433
+ was not that reboot. Giving up has nothing to recommend it — the triggers for
434
+ another attempt are the reconnect edge and the retained message, and a panel
435
+ that finishes booting afterwards produces neither, so exhausting a bound
436
+ means stranded until a human reloads.
437
+ """
438
+ client, _ = _client(None)
439
+ attempts = 0
440
+
441
+ def _ready_far_later(*_a: object, **_k: object) -> _Schema:
442
+ nonlocal attempts
443
+ attempts += 1
444
+ if attempts < 40: # well past any bound this ever had
445
+ raise SpanPanelServerError("Panel not ready: HTTP 502", 502)
446
+ return _Schema("1.0")
447
+
448
+ with (
449
+ patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_ready_far_later),
450
+ patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 0),
451
+ patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 0),
452
+ ):
453
+ assert await client._fetch_schema_with_retry() is not None
454
+
455
+ assert attempts == 40
456
+
457
+
458
+ @pytest.mark.asyncio
459
+ async def test_the_wait_ends_promptly_when_the_client_is_closed() -> None:
460
+ """Unbounded is only safe because cancellation is prompt.
461
+
462
+ `close()` cancels every background task, and the cancellation lands inside
463
+ the sleep. Without this, waiting forever would mean a Home Assistant
464
+ shutdown or a config-entry unload waiting with it.
465
+ """
466
+ client, _ = _client(None)
467
+
468
+ with (
469
+ patch(
470
+ "span_panel_api.mqtt.client.get_homie_schema",
471
+ side_effect=SpanPanelServerError("Panel not ready: HTTP 502", 502),
472
+ ),
473
+ patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 3600),
474
+ patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 3600),
475
+ ):
476
+ task = asyncio.create_task(client._fetch_schema_with_retry())
477
+ await asyncio.sleep(0)
478
+ task.cancel()
479
+ with pytest.raises(asyncio.CancelledError):
480
+ await task
481
+
482
+ assert task.cancelled()