span-panel-api 3.0.0b7__tar.gz → 3.0.0b9__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.0b7 → span_panel_api-3.0.0b9}/CHANGELOG.md +21 -0
  2. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/PKG-INFO +1 -1
  3. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/pyproject.toml +1 -1
  4. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/auth.py +22 -2
  5. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/mqtt/client.py +45 -3
  6. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_auth_and_homie_helpers.py +44 -2
  7. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_redispatch_on_reconnect.py +98 -2
  8. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_one_conformance.py +51 -12
  9. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/.gitignore +0 -0
  10. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/LICENSE +0 -0
  11. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/README.md +0 -0
  12. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/__init__.py +0 -0
  13. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/_http.py +0 -0
  14. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/adapters.py +0 -0
  15. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/const.py +0 -0
  16. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/detection.py +0 -0
  17. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/dispatch.py +0 -0
  18. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/exceptions.py +0 -0
  19. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/factory.py +0 -0
  20. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/models.py +0 -0
  21. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/mqtt/__init__.py +0 -0
  22. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/mqtt/async_client.py +0 -0
  23. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/mqtt/connection.py +0 -0
  24. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/mqtt/const.py +0 -0
  25. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/mqtt/models.py +0 -0
  26. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/phase_validation.py +0 -0
  27. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/protocol.py +0 -0
  28. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/py.typed +0 -0
  29. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/reference_payloads/README.md +0 -0
  30. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/reference_payloads/__init__.py +0 -0
  31. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/reference_payloads/homie_schema.json +0 -0
  32. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/src/span_panel_api/schema_drift.py +0 -0
  33. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/conftest.py +0 -0
  34. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  35. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  36. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  37. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/fixtures/flat_wire.json +0 -0
  38. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  39. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/fixtures/v2/README.md +0 -0
  40. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/fixtures/v2/status.json +0 -0
  41. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/simulation_fixtures/circuits.response.txt +0 -0
  42. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/simulation_fixtures/panel.response.txt +0 -0
  43. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/simulation_fixtures/soe.response.txt +0 -0
  44. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/simulation_fixtures/status.response.txt +0 -0
  45. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_accumulator.py +0 -0
  46. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_adapters_discovery.py +0 -0
  47. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_adopted_control.py +0 -0
  48. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_adoption.py +0 -0
  49. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_async_mqtt_client.py +0 -0
  50. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_catalog_divergence.py +0 -0
  51. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_detection_auth.py +0 -0
  52. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_exceptions.py +0 -0
  53. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_factory_dispatch.py +0 -0
  54. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_field_metadata.py +0 -0
  55. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_live_flat_differential.py +0 -0
  56. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_mqtt_bridge.py +0 -0
  57. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_mqtt_client_connection.py +0 -0
  58. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_mqtt_connect_flow.py +0 -0
  59. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_mqtt_debounce.py +0 -0
  60. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_mqtt_homie.py +0 -0
  61. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_packaging.py +0 -0
  62. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_phase_validation_configs.py +0 -0
  63. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_phase_validation_errors.py +0 -0
  64. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_protocol_conformance.py +0 -0
  65. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_protocol_models.py +0 -0
  66. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_public_api_unchanged.py +0 -0
  67. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_reference_tree_values.py +0 -0
  68. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_generation_cross_check.py +0 -0
  69. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_migration_delta.py +0 -0
  70. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_one_adapter.py +0 -0
  71. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_one_against_simulator.py +0 -0
  72. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_one_charge_limit.py +0 -0
  73. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_one_circuits.py +0 -0
  74. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_one_connection_health.py +0 -0
  75. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_one_devices.py +0 -0
  76. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_one_discovery.py +0 -0
  77. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_one_panel.py +0 -0
  78. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_one_pcs.py +0 -0
  79. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_one_service_entrance.py +0 -0
  80. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_one_shed_forecast.py +0 -0
  81. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_one_snapshot.py +0 -0
  82. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_one_transport.py +0 -0
  83. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_provenance.py +0 -0
  84. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_schema_zero_adapter.py +0 -0
  85. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b9}/tests/test_shared_http_client.py +0 -0
@@ -4,6 +4,27 @@ 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.0b9]
8
+
9
+ ### Fixed
10
+
11
+ - **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
12
+ 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
13
+ 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.
14
+
15
+ ## [3.0.0b8]
16
+
17
+ ### Fixed
18
+
19
+ - **A panel answering `502` while it reboots no longer costs the automatic reload.** When a panel upgrades its firmware it drops MQTT, comes back, and serves HTTP a little later — and a booting device brings its network stack and reverse proxy up before
20
+ the application behind them, so the schema fetch is answered with `502` rather than refused. The retry that exists for exactly this handled "cannot reach" and "timed out" but not "answered, with 502", so the first attempt raised straight out of the loop,
21
+ out of the fire-and-forget task that called it, and the parser was never swapped. Caught on two Home Assistant instances watching one panel through the same live upgrade: both logged `Task exception was never retrieved`, both stayed on the old parser,
22
+ and neither recovered without a manual reload. `get_homie_schema` now raises `SpanPanelServerError` for any 5xx — "not ready yet", distinct from a 4xx that will not fix itself — and the retry treats it as retryable.
23
+ - **The wait is now the length of a real reboot.** Five attempts backing off to 8s gave up after about 23 seconds. The observed upgrade took four minutes from MQTT dropping to the broker returning, with HTTP still answering 502 at that point. Twelve
24
+ attempts backing off to 30s covers it.
25
+ - **Nothing escapes the redispatch task any more.** An unexpected failure there used to surface as a bare `Task exception was never retrieved` while the parser silently stayed on the old generation — the failure the redispatch exists to prevent, reached by
26
+ another route. It is now logged at ERROR naming the consequence and the remedy, because a reload is the user's only move and nothing else was going to tell them.
27
+
7
28
  ## [3.0.0b7]
8
29
 
9
30
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 3.0.0b7
3
+ Version: 3.0.0b9
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.0b7"
3
+ version = "3.0.0b9"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -15,7 +15,13 @@ import uuid
15
15
  import httpx
16
16
 
17
17
  from ._http import _build_url, _get_client
18
- from .exceptions import SpanPanelAPIError, SpanPanelAuthError, SpanPanelConnectionError, SpanPanelTimeoutError
18
+ from .exceptions import (
19
+ SpanPanelAPIError,
20
+ SpanPanelAuthError,
21
+ SpanPanelConnectionError,
22
+ SpanPanelServerError,
23
+ SpanPanelTimeoutError,
24
+ )
19
25
  from .models import HomieSchemaTypes, V2AuthResponse, V2HomieSchema, V2StatusInfo
20
26
 
21
27
 
@@ -187,8 +193,22 @@ async def get_homie_schema(
187
193
  except httpx.TimeoutException as exc:
188
194
  raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc
189
195
 
196
+ if response.status_code >= 500:
197
+ # A rebooting panel answers 502 from its front end while the application
198
+ # behind it is still starting. That is "not ready yet", not "wrong" --
199
+ # and it is the ordinary shape of a firmware upgrade, because a device
200
+ # brings its network stack and proxy up before its application. Raised as
201
+ # a distinct class so a caller can retry it and fail fast on a 4xx, which
202
+ # will not fix itself.
203
+ raise SpanPanelServerError(
204
+ f"Panel not ready: HTTP {response.status_code} fetching the Homie schema",
205
+ status_code=response.status_code,
206
+ )
190
207
  if response.status_code != 200:
191
- raise SpanPanelAPIError(f"Failed to fetch Homie schema: HTTP {response.status_code}")
208
+ raise SpanPanelAPIError(
209
+ f"Failed to fetch Homie schema: HTTP {response.status_code}",
210
+ status_code=response.status_code,
211
+ )
192
212
 
193
213
  data: dict[str, object] = response.json()
194
214
 
@@ -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
51
+ _REDISPATCH_RETRY_ATTEMPTS = 12
52
52
  _REDISPATCH_RETRY_INITIAL_S = 1.0
53
- _REDISPATCH_RETRY_MAX_S = 8.0
53
+ _REDISPATCH_RETRY_MAX_S = 30.0
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]:
@@ -823,7 +835,20 @@ class SpanMqttClient:
823
835
  for _ in range(_REDISPATCH_RETRY_ATTEMPTS):
824
836
  try:
825
837
  return await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client)
826
- except (SpanPanelConnectionError, SpanPanelTimeoutError) as exc:
838
+ except (
839
+ SpanPanelConnectionError,
840
+ 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.
850
+ SpanPanelServerError,
851
+ ) as exc:
827
852
  last = exc
828
853
  await asyncio.sleep(delay)
829
854
  delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S)
@@ -862,6 +887,23 @@ class SpanMqttClient:
862
887
  """
863
888
  try:
864
889
  await self._redispatch_once()
890
+ except Exception: # pylint: disable=broad-exception-caught
891
+ # Nothing may escape here. This runs as a fire-and-forget task, so an
892
+ # escaping exception becomes "Task exception was never retrieved" in
893
+ # the log and the parser silently stays on the old generation --
894
+ # which is the failure this whole method exists to prevent, arrived at
895
+ # by a different route. That is not hypothetical: a 502 from a
896
+ # rebooting panel did exactly this on two live installs.
897
+ #
898
+ # Logged at ERROR with the consequence spelled out, because the user's
899
+ # remedy is a reload and nothing else will tell them so.
900
+ _LOGGER.error(
901
+ "Could not follow the panel's schema-generation change; the %r parser is "
902
+ "unchanged and its data will read as missing. Reload the integration once "
903
+ "the panel is fully back up.",
904
+ self._data_model_version,
905
+ exc_info=True,
906
+ )
865
907
  finally:
866
908
  # Released only when the swap is finished, not when the fetch is.
867
909
  # Clearing it after the fetch left a window that the slowest step in
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from unittest.mock import AsyncMock, patch
6
+ from unittest.mock import AsyncMock, MagicMock, patch
7
7
 
8
8
  import httpx
9
9
  import pytest
@@ -11,7 +11,12 @@ import pytest
11
11
  from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator
12
12
  from span_panel_api_schema_0.consumer import HomieDeviceConsumer, _parse_int
13
13
  from span_panel_api.auth import _int, download_ca_cert, get_homie_schema
14
- from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelTimeoutError
14
+ from span_panel_api.exceptions import (
15
+ SpanPanelAPIError,
16
+ SpanPanelConnectionError,
17
+ SpanPanelServerError,
18
+ SpanPanelTimeoutError,
19
+ )
15
20
 
16
21
  # ---------------------------------------------------------------------------
17
22
  # auth._int edge cases (lines 29-31)
@@ -34,6 +39,17 @@ class TestIntHelper:
34
39
  # ---------------------------------------------------------------------------
35
40
 
36
41
 
42
+ def _mock_response(method: str, status_code: int) -> AsyncMock:
43
+ """A client whose request completes and returns `status_code`."""
44
+ response = MagicMock()
45
+ response.status_code = status_code
46
+ mock = AsyncMock()
47
+ setattr(mock, method, AsyncMock(return_value=response))
48
+ mock.__aenter__ = AsyncMock(return_value=mock)
49
+ mock.__aexit__ = AsyncMock(return_value=False)
50
+ return mock
51
+
52
+
37
53
  def _mock_client(method: str, side_effect: Exception) -> AsyncMock:
38
54
  mock = AsyncMock()
39
55
  setattr(mock, method, AsyncMock(side_effect=side_effect))
@@ -78,6 +94,32 @@ class TestGetHomieSchemaErrors:
78
94
  with pytest.raises(SpanPanelTimeoutError):
79
95
  await get_homie_schema("192.168.1.1")
80
96
 
97
+ @pytest.mark.asyncio
98
+ @pytest.mark.parametrize("status", [500, 502, 503, 504])
99
+ async def test_a_server_status_is_not_ready_rather_than_wrong(self, status: int) -> None:
100
+ """A rebooting panel answers from its front end while the app behind it starts.
101
+
102
+ Raised as `SpanPanelServerError` so a caller can tell "not yet" from
103
+ "no". The redispatch retry depends on this distinction: a live firmware
104
+ upgrade produced 502 here, the retry loop did not catch the general
105
+ `SpanPanelAPIError` it used to be, and the parser was never swapped.
106
+ """
107
+ with patch("span_panel_api._http.httpx.AsyncClient") as cls:
108
+ cls.return_value = _mock_response("get", status)
109
+ with pytest.raises(SpanPanelServerError) as caught:
110
+ await get_homie_schema("192.168.1.1")
111
+ assert caught.value.status_code == status
112
+
113
+ @pytest.mark.asyncio
114
+ @pytest.mark.parametrize("status", [401, 404])
115
+ async def test_a_client_status_is_not_retryable(self, status: int) -> None:
116
+ """These do not fix themselves, so they must not look like "not ready yet"."""
117
+ with patch("span_panel_api._http.httpx.AsyncClient") as cls:
118
+ cls.return_value = _mock_response("get", status)
119
+ with pytest.raises(SpanPanelAPIError) as caught:
120
+ await get_homie_schema("192.168.1.1")
121
+ assert not isinstance(caught.value, SpanPanelServerError)
122
+
81
123
 
82
124
  # ---------------------------------------------------------------------------
83
125
  # homie._parse_int failure path (lines 51-52)
@@ -30,8 +30,13 @@ from unittest.mock import patch
30
30
 
31
31
  import pytest
32
32
 
33
- from span_panel_api.exceptions import SpanPanelConnectionError
34
- from span_panel_api.mqtt.client import _REDISPATCH_RETRY_ATTEMPTS, SpanMqttClient
33
+ from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelServerError
34
+ from span_panel_api.mqtt.client import (
35
+ _REDISPATCH_RETRY_ATTEMPTS,
36
+ _REDISPATCH_RETRY_INITIAL_S,
37
+ _REDISPATCH_RETRY_MAX_S,
38
+ SpanMqttClient,
39
+ )
35
40
  from span_panel_api.mqtt.models import MqttClientConfig
36
41
 
37
42
  from conftest import SERIAL
@@ -299,3 +304,94 @@ async def test_a_raising_consumer_does_not_break_the_swap() -> None:
299
304
 
300
305
  assert client.data_model_version == "1.0", "the swap must stand"
301
306
  assert reached == ["second"], "one raising subscriber must not starve the others"
307
+
308
+
309
+ @pytest.mark.asyncio
310
+ async def test_a_rebooting_panel_answering_502_is_waited_for_not_abandoned() -> None:
311
+ """The failure that cost a live firmware upgrade its automatic reload.
312
+
313
+ A panel accepts MQTT before it serves HTTP, and the retry loop above exists
314
+ for that. But there are three ways HTTP lags the broker, and this loop
315
+ originally handled two: it caught "cannot reach" and "timed out" and not
316
+ "answered, with 502". A booting device brings its network stack and reverse
317
+ proxy up before the application behind them, so 502 is the *ordinary* shape,
318
+ not an exotic one.
319
+
320
+ Because `SpanPanelServerError` was not caught, the very first attempt raised
321
+ straight out of the loop, out of the fire-and-forget task that called it, and
322
+ the parser was never swapped. Observed on two Home Assistant instances
323
+ watching one panel through the same upgrade: both logged `Task exception was
324
+ never retrieved`, both stayed on the flat parser, and neither recovered
325
+ without a manual reload.
326
+ """
327
+ client, _ = _client(None)
328
+ before = client.adapter
329
+ attempts = 0
330
+
331
+ def _five_oh_two_then_ready(*_a: object, **_k: object) -> _Schema:
332
+ nonlocal attempts
333
+ attempts += 1
334
+ if attempts < 3:
335
+ raise SpanPanelServerError("Panel not ready: HTTP 502 fetching the Homie schema", 502)
336
+ return _Schema("1.0")
337
+
338
+ with (
339
+ patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_five_oh_two_then_ready),
340
+ patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 0),
341
+ patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 0),
342
+ ):
343
+ await _panel_publishes_version(client, "1.0")
344
+
345
+ assert attempts >= 3, "a 502 must be retried rather than ending the attempt"
346
+ assert client.adapter is not before, "the parser must swap once the panel answers"
347
+
348
+
349
+ @pytest.mark.asyncio
350
+ async def test_an_unexpected_failure_leaves_a_usable_message_rather_than_a_bare_traceback(
351
+ caplog: pytest.LogCaptureFixture,
352
+ ) -> None:
353
+ """Nothing may escape the fire-and-forget task.
354
+
355
+ An escaping exception surfaces as "Task exception was never retrieved" and
356
+ the parser silently stays on the old generation -- the exact failure this
357
+ method exists to prevent, reached by a different route. The user's remedy is
358
+ a reload, and nothing else is going to tell them so.
359
+ """
360
+ client, _ = _client(None)
361
+ before = client.adapter
362
+
363
+ with patch(
364
+ "span_panel_api.mqtt.client.get_homie_schema",
365
+ side_effect=RuntimeError("something nobody predicted"),
366
+ ):
367
+ await _panel_publishes_version(client, "1.0")
368
+
369
+ assert client.adapter is before
370
+ assert "Reload the integration" in caplog.text
371
+ assert "something nobody predicted" in caplog.text
372
+
373
+
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.
376
+
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.
381
+
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"
397
+ )
@@ -37,6 +37,7 @@ import ast
37
37
  import importlib
38
38
  import json
39
39
  import os
40
+ import subprocess
40
41
  from pathlib import Path
41
42
  import re
42
43
  from typing import NoReturn
@@ -572,26 +573,64 @@ def test_nothing_is_recorded_as_unexercised_once_the_simulator_publishes_it() ->
572
573
 
573
574
 
574
575
  def test_vendored_catalogs_are_byte_identical_to_the_specification() -> None:
575
- """Byte comparison against the specification at `synced_commit`.
576
-
577
- Skipped rather than failed without a checkout: the checks above are the ones
578
- that must run everywhere, and making them depend on a second repository would
579
- mean they stop running.
576
+ """Are the bytes we vendored the bytes we claim they are?
577
+
578
+ Read out of git **at `synced_commit`** rather than from the checkout's working
579
+ tree, so the answer does not depend on what that clone happens to be sitting
580
+ on. This used to read the working tree while its own docstring claimed
581
+ otherwise, and `synced_commit` appeared only in the failure message. That is
582
+ wrong three ways, and one of them is the dangerous one:
583
+
584
+ * it **fails** when the clone has moved *ahead* of the pin, which is ordinary
585
+ currency drift and not a defect here -- observed the day the specification
586
+ went to `power-flows` 0.3;
587
+ * it **fails spuriously** with the clone on an unrelated branch;
588
+ * it **passes falsely** with a clone itself stale at the pinned commit while
589
+ the specification has moved on.
590
+
591
+ **Integrity, deliberately not currency.** Whether upstream has moved past our
592
+ pin is a separate question whose answer is normally "yes, a little", and it
593
+ must not fail a build. Conflating the two is what made this unreliable.
594
+ Currency is not checked by anything automatic here, and wants a scheduled job
595
+ rather than a gate.
596
+
597
+ The upstream reference producer fixed the same defect in its own copy of this
598
+ check (`distribution-enclosure-simulator` #47), which is where the framing
599
+ comes from.
580
600
  """
581
601
  spec = _checkout(
582
602
  "EBUS_SPEC_DIR",
583
603
  "a specification checkout to verify vendored bytes",
584
604
  expect="capabilities",
585
605
  )
586
- differing = [
587
- path.name
588
- for path in sorted(_CATALOGS.glob("*.json"))
589
- if (spec / "capabilities" / path.name).read_bytes() != path.read_bytes()
590
- ]
606
+ commit = _lock()["synced_commit"]
607
+ differing: list[str] = []
608
+ for path in sorted(_CATALOGS.glob("*.json")):
609
+ blob = subprocess.run(
610
+ ["git", "-C", str(spec), "show", f"{commit}:capabilities/{path.name}"],
611
+ capture_output=True,
612
+ # Stripped, because `-C` does not beat them. Git hooks export `GIT_DIR`
613
+ # and `GIT_INDEX_FILE` pointing at the repository being committed to,
614
+ # and an exported `GIT_DIR` wins over directory discovery -- so under
615
+ # pre-commit this read the *consumer's* object store, could not find a
616
+ # specification commit there, and failed with a fetch instruction for a
617
+ # commit the clone already had. Caught by the hook that causes it.
618
+ env={k: v for k, v in os.environ.items() if not k.startswith("GIT_")},
619
+ )
620
+ if blob.returncode != 0:
621
+ # A clone that cannot resolve the pin fails rather than skipping: a
622
+ # silent skip reads exactly like a pass on the one check that proves
623
+ # the vendored bytes are what the lockfile says.
624
+ pytest.fail(
625
+ f"{spec} cannot resolve {commit} (needed to read capabilities/{path.name}). "
626
+ f"Fetch it: git -C {spec} fetch origin {commit}"
627
+ )
628
+ if blob.stdout != path.read_bytes():
629
+ differing.append(path.name)
591
630
 
592
631
  assert not differing, (
593
- f"vendored catalogs differ from {spec} (lockfile pins {_lock()['synced_commit']}): {differing}. "
594
- "Check the checkout is at synced_commit before assuming the copies are wrong."
632
+ f"vendored catalogs differ from the specification at {commit}: {differing}. "
633
+ "These are byte copies, so this is a vendoring defect rather than upstream having moved."
595
634
  )
596
635
 
597
636