span-panel-api 3.0.0b9__tar.gz → 3.0.0b11__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.0b9 → span_panel_api-3.0.0b11}/CHANGELOG.md +27 -0
  2. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/PKG-INFO +1 -1
  3. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/pyproject.toml +1 -1
  4. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/auth.py +28 -3
  5. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/exceptions.py +7 -1
  6. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/mqtt/client.py +41 -25
  7. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_auth_and_homie_helpers.py +52 -0
  8. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_redispatch_on_reconnect.py +117 -32
  9. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_panel.py +33 -1
  10. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/.gitignore +0 -0
  11. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/LICENSE +0 -0
  12. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/README.md +0 -0
  13. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/__init__.py +0 -0
  14. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/_http.py +0 -0
  15. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/adapters.py +0 -0
  16. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/const.py +0 -0
  17. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/detection.py +0 -0
  18. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/dispatch.py +0 -0
  19. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/factory.py +0 -0
  20. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/models.py +0 -0
  21. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/mqtt/__init__.py +0 -0
  22. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/mqtt/async_client.py +0 -0
  23. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/mqtt/connection.py +0 -0
  24. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/mqtt/const.py +0 -0
  25. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/mqtt/models.py +0 -0
  26. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/phase_validation.py +0 -0
  27. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/protocol.py +0 -0
  28. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/py.typed +0 -0
  29. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/reference_payloads/README.md +0 -0
  30. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/reference_payloads/__init__.py +0 -0
  31. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/reference_payloads/homie_schema.json +0 -0
  32. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/schema_drift.py +0 -0
  33. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/conftest.py +0 -0
  34. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  35. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  36. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  37. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/flat_wire.json +0 -0
  38. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  39. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/v2/README.md +0 -0
  40. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/v2/status.json +0 -0
  41. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/simulation_fixtures/circuits.response.txt +0 -0
  42. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/simulation_fixtures/panel.response.txt +0 -0
  43. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/simulation_fixtures/soe.response.txt +0 -0
  44. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/simulation_fixtures/status.response.txt +0 -0
  45. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_accumulator.py +0 -0
  46. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_adapters_discovery.py +0 -0
  47. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_adopted_control.py +0 -0
  48. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_adoption.py +0 -0
  49. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_async_mqtt_client.py +0 -0
  50. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_catalog_divergence.py +0 -0
  51. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_detection_auth.py +0 -0
  52. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_exceptions.py +0 -0
  53. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_factory_dispatch.py +0 -0
  54. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_field_metadata.py +0 -0
  55. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_live_flat_differential.py +0 -0
  56. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_mqtt_bridge.py +0 -0
  57. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_mqtt_client_connection.py +0 -0
  58. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_mqtt_connect_flow.py +0 -0
  59. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_mqtt_debounce.py +0 -0
  60. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_mqtt_homie.py +0 -0
  61. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_packaging.py +0 -0
  62. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_phase_validation_configs.py +0 -0
  63. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_phase_validation_errors.py +0 -0
  64. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_protocol_conformance.py +0 -0
  65. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_protocol_models.py +0 -0
  66. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_public_api_unchanged.py +0 -0
  67. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_reference_tree_values.py +0 -0
  68. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_generation_cross_check.py +0 -0
  69. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_migration_delta.py +0 -0
  70. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_adapter.py +0 -0
  71. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_against_simulator.py +0 -0
  72. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_charge_limit.py +0 -0
  73. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_circuits.py +0 -0
  74. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_conformance.py +0 -0
  75. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_connection_health.py +0 -0
  76. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_devices.py +0 -0
  77. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_discovery.py +0 -0
  78. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_pcs.py +0 -0
  79. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_service_entrance.py +0 -0
  80. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_shed_forecast.py +0 -0
  81. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_snapshot.py +0 -0
  82. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_transport.py +0 -0
  83. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_provenance.py +0 -0
  84. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_zero_adapter.py +0 -0
  85. {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_shared_http_client.py +0 -0
@@ -4,6 +4,33 @@ 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.0b11]
8
+
9
+ Carries `span-panel-api-schema-1` 0.1.0b8, which restores `dominant_power_source` on a panel with no MID. No change in this distribution.
10
+
11
+ ## [3.0.0b10]
12
+
13
+ ### Changed
14
+
15
+ - **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
16
+ 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
17
+ of attempts means stranded until somebody reloads by hand. It now waits as long as the panel takes.
18
+ - **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
19
+ 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.
20
+ - **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
21
+ panel is answering it is noticed within half a minute however long the wait has already run.
22
+
23
+ ### Fixed
24
+
25
+ - **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
26
+ 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
27
+ entirely, and stranded the parser exactly as the 502 did. Transport failures are now `SpanPanelConnectionError` and an unusable body is `SpanPanelServerError`.
28
+ - **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
29
+ 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
30
+ restating the implementation's own off-by-one.
31
+ - **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
32
+ produces neither again, so exhausting the window means stuck until a reload. It now says so.
33
+
7
34
  ## [3.0.0b9]
8
35
 
9
36
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 3.0.0b9
3
+ Version: 3.0.0b11
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.0b9"
3
+ version = "3.0.0b11"
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,9 @@ _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 = 12
52
51
  _REDISPATCH_RETRY_INITIAL_S = 1.0
53
52
  _REDISPATCH_RETRY_MAX_S = 30.0
53
+ _REDISPATCH_LOG_EVERY = 20
54
54
  """How long to wait for the panel's HTTP endpoint after it returns on MQTT.
55
55
 
56
56
  Sized from a live firmware upgrade rather than guessed. The panel dropped MQTT at
@@ -817,7 +817,7 @@ class SpanMqttClient:
817
817
  return active != observed
818
818
 
819
819
  async def _fetch_schema_with_retry(self) -> V2HomieSchema | None:
820
- """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.
821
821
 
822
822
  A panel that has just restarted accepts MQTT before it serves HTTP — the
823
823
  broker is listening while the application is still binding its port. The
@@ -826,40 +826,56 @@ class SpanMqttClient:
826
826
  was no further edge to retry on, leaving the wrong parser in place for the
827
827
  rest of the session.
828
828
 
829
- So this waits, briefly and boundedly. Returning None rather than raising
830
- because the caller's job is to reconsider the parser, and being unable to
831
- 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.
832
849
  """
833
850
  delay = _REDISPATCH_RETRY_INITIAL_S
834
- last: Exception | None = None
835
- for _ in range(_REDISPATCH_RETRY_ATTEMPTS):
851
+ attempts = 0
852
+ while True:
836
853
  try:
837
854
  return await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client)
838
855
  except (
839
856
  SpanPanelConnectionError,
840
857
  SpanPanelTimeoutError,
841
- # The third way HTTP lags the broker, and the one a real upgrade
842
- # actually produced: the panel answers, with 502. Its front end is
843
- # up while the application behind it is still starting, which is
844
- # the ordinary order for a booting device. Omitting this meant the
845
- # first attempt raised straight out of this loop, out of the
846
- # fire-and-forget task that called it, and the parser was never
847
- # swapped -- observed on two Home Assistant instances watching one
848
- # panel through the same upgrade, neither of which recovered
849
- # 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.
850
864
  SpanPanelServerError,
851
865
  ) as exc:
852
- 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
+ )
853
877
  await asyncio.sleep(delay)
854
878
  delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S)
855
- _LOGGER.warning(
856
- "Could not re-read the panel schema after %d attempts (%s). The active "
857
- "parser is unchanged; if the panel's schema generation did change, its "
858
- "data will read as missing until the next reconnect.",
859
- _REDISPATCH_RETRY_ATTEMPTS,
860
- last,
861
- )
862
- return None
863
879
 
864
880
  async def _redispatch_if_generation_changed(self) -> None:
865
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")
@@ -32,7 +32,6 @@ import pytest
32
32
 
33
33
  from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelServerError
34
34
  from span_panel_api.mqtt.client import (
35
- _REDISPATCH_RETRY_ATTEMPTS,
36
35
  _REDISPATCH_RETRY_INITIAL_S,
37
36
  _REDISPATCH_RETRY_MAX_S,
38
37
  SpanMqttClient,
@@ -101,7 +100,7 @@ async def _panel_publishes_version(client: SpanMqttClient, version: str | None)
101
100
  # The refetch is scheduled rather than awaited, so the message callback can stay
102
101
  # synchronous. Let the loop drain it.
103
102
  # One turn per retry attempt, plus slack for the task itself.
104
- for _ in range(_REDISPATCH_RETRY_ATTEMPTS + 4):
103
+ for _ in range(24):
105
104
  await asyncio.sleep(0)
106
105
 
107
106
 
@@ -215,13 +214,15 @@ async def test_http_lagging_the_broker_is_retried_not_abandoned() -> None:
215
214
 
216
215
 
217
216
  @pytest.mark.asyncio
218
- async def test_a_panel_that_never_serves_http_leaves_the_parser_alone() -> None:
219
- """Bounded, and non-fatal when the bound is reached.
220
-
221
- MQTT is up or this path would not be running, so tearing the connection down over
222
- an unreachable HTTP endpoint would turn a degraded panel into a dead integration.
223
- A stale parser reports missing data rather than wrong data, because the two
224
- 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.
225
226
  """
226
227
  client, _ = _client(None)
227
228
  before = client.adapter
@@ -236,8 +237,16 @@ async def test_a_panel_that_never_serves_http_leaves_the_parser_alone() -> None:
236
237
  ):
237
238
  await _panel_publishes_version(client, "1.0")
238
239
 
239
- assert client.adapter is before
240
- 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()
241
250
 
242
251
 
243
252
  @pytest.mark.asyncio
@@ -371,27 +380,103 @@ async def test_an_unexpected_failure_leaves_a_usable_message_rather_than_a_bare_
371
380
  assert "something nobody predicted" in caplog.text
372
381
 
373
382
 
374
- def test_the_retry_window_outlasts_a_real_panel_reboot() -> None:
375
- """Catching the 502 buys nothing if the loop gives up before the panel is ready.
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")
376
409
 
377
- Measured rather than assumed. On a live firmware upgrade the panel dropped
378
- MQTT at 11:22:07 and the broker was back at 11:26:15 — four minutes — and its
379
- HTTP front end was still answering 502 at that moment, which is when this
380
- loop starts.
410
+ async def _record(seconds: float) -> None:
411
+ slept.append(seconds)
381
412
 
382
- Pinned as a total because the three constants only mean something together,
383
- and because the widening was written once, lost to a failed edit, and shipped
384
- without it. Nothing failed: the 502 was caught and the loop still gave up
385
- after twenty-three seconds. A test on the constants is the only thing that
386
- would have noticed.
387
- """
388
- delay = _REDISPATCH_RETRY_INITIAL_S
389
- total = 0.0
390
- for _ in range(_REDISPATCH_RETRY_ATTEMPTS):
391
- total += delay
392
- delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S)
393
-
394
- observed_reboot_s = 4 * 60
395
- assert total >= observed_reboot_s, (
396
- f"the retry window is {total:.0f}s, shorter than the {observed_reboot_s}s reboot " "this loop exists to wait out"
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"
397
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()
@@ -445,7 +445,39 @@ def test_an_unresolvable_forming_entity_cannot_escape_as_a_raw_id() -> None:
445
445
 
446
446
  assert resolve_dominant_power_source(stranger, {}) == "UNKNOWN"
447
447
  assert resolve_dominant_power_source(unmapped, {"wh-1": "energy.ebus.device.water-heater"}) == "UNKNOWN"
448
- assert resolve_dominant_power_source(None, {}) is None
448
+
449
+
450
+ def test_a_panel_with_no_mid_reports_the_grid_as_forming() -> None:
451
+ """Elimination, not a guess, and it restores an answer flat already gave.
452
+
453
+ A commissioned MID is what SPAN islands with, so its absence rules out every
454
+ other value this field can take. `BATTERY` needs a BESS and a BESS brings a
455
+ MID; `PV` cannot form a grid alone, because anything that can is a
456
+ grid-forming inverter and therefore a MID; `NONE` describes a panel supplying
457
+ nothing, which is a panel that is not publishing. What remains is a generator,
458
+ and that is two cases of which only one reaches here — one wired through a MID
459
+ is named by that MID and answered before this point, while one with no MID
460
+ interface is what SPAN treats as the grid, and is the only kind an install
461
+ with no MID can have. So this keeps holding if MID-integrated generators
462
+ arrive: they bring a MID.
463
+
464
+ Observed: a live no-BESS panel read `Grid` on flat all night and went
465
+ `Unknown` the moment it upgraded, because the property moved onto a device
466
+ that install does not have. Nothing about the site changed.
467
+ """
468
+ assert resolve_dominant_power_source(None, {}) == "GRID"
469
+
470
+
471
+ def test_a_mid_that_has_not_answered_is_unknown_rather_than_grid() -> None:
472
+ """Distinct from having no MID at all, and the distinction is the whole point.
473
+
474
+ An islanding authority exists and has not said what is forming the grid. That
475
+ is genuinely unknown — unlike an install with no such authority, where the
476
+ answer is settled by what cannot be there.
477
+ """
478
+ silent = _synthetic("mid", grid__grid_forming_entity="")
479
+
480
+ assert resolve_dominant_power_source(silent, {}) is None
449
481
 
450
482
 
451
483
  def test_the_forming_device_is_named_readably_not_by_wire_id() -> None: