span-panel-api 3.0.0b7__tar.gz → 3.0.0b8__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.0b8}/CHANGELOG.md +13 -0
  2. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/PKG-INFO +1 -1
  3. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/pyproject.toml +1 -1
  4. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/auth.py +22 -2
  5. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/client.py +31 -1
  6. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_auth_and_homie_helpers.py +44 -2
  7. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_redispatch_on_reconnect.py +66 -1
  8. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_conformance.py +51 -12
  9. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/.gitignore +0 -0
  10. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/LICENSE +0 -0
  11. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/README.md +0 -0
  12. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/__init__.py +0 -0
  13. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/_http.py +0 -0
  14. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/adapters.py +0 -0
  15. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/const.py +0 -0
  16. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/detection.py +0 -0
  17. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/dispatch.py +0 -0
  18. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/exceptions.py +0 -0
  19. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/factory.py +0 -0
  20. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/models.py +0 -0
  21. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/__init__.py +0 -0
  22. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/async_client.py +0 -0
  23. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/connection.py +0 -0
  24. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/const.py +0 -0
  25. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/models.py +0 -0
  26. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/phase_validation.py +0 -0
  27. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/protocol.py +0 -0
  28. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/py.typed +0 -0
  29. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/reference_payloads/README.md +0 -0
  30. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/reference_payloads/__init__.py +0 -0
  31. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/reference_payloads/homie_schema.json +0 -0
  32. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/schema_drift.py +0 -0
  33. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/conftest.py +0 -0
  34. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  35. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  36. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  37. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/flat_wire.json +0 -0
  38. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  39. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/v2/README.md +0 -0
  40. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/v2/status.json +0 -0
  41. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/circuits.response.txt +0 -0
  42. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/panel.response.txt +0 -0
  43. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/soe.response.txt +0 -0
  44. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/status.response.txt +0 -0
  45. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_accumulator.py +0 -0
  46. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_adapters_discovery.py +0 -0
  47. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_adopted_control.py +0 -0
  48. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_adoption.py +0 -0
  49. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_async_mqtt_client.py +0 -0
  50. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_catalog_divergence.py +0 -0
  51. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_detection_auth.py +0 -0
  52. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_exceptions.py +0 -0
  53. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_factory_dispatch.py +0 -0
  54. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_field_metadata.py +0 -0
  55. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_live_flat_differential.py +0 -0
  56. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_mqtt_bridge.py +0 -0
  57. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_mqtt_client_connection.py +0 -0
  58. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_mqtt_connect_flow.py +0 -0
  59. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_mqtt_debounce.py +0 -0
  60. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_mqtt_homie.py +0 -0
  61. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_packaging.py +0 -0
  62. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_phase_validation_configs.py +0 -0
  63. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_phase_validation_errors.py +0 -0
  64. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_protocol_conformance.py +0 -0
  65. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_protocol_models.py +0 -0
  66. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_public_api_unchanged.py +0 -0
  67. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_reference_tree_values.py +0 -0
  68. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_generation_cross_check.py +0 -0
  69. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_migration_delta.py +0 -0
  70. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_adapter.py +0 -0
  71. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_against_simulator.py +0 -0
  72. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_charge_limit.py +0 -0
  73. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_circuits.py +0 -0
  74. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_connection_health.py +0 -0
  75. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_devices.py +0 -0
  76. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_discovery.py +0 -0
  77. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_panel.py +0 -0
  78. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_pcs.py +0 -0
  79. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_service_entrance.py +0 -0
  80. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_shed_forecast.py +0 -0
  81. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_snapshot.py +0 -0
  82. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_transport.py +0 -0
  83. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_provenance.py +0 -0
  84. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_zero_adapter.py +0 -0
  85. {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_shared_http_client.py +0 -0
@@ -4,6 +4,19 @@ 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.0b8]
8
+
9
+ ### Fixed
10
+
11
+ - **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
12
+ 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,
13
+ 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,
14
+ 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.
15
+ - **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
16
+ attempts backing off to 30s covers it.
17
+ - **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
18
+ 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.
19
+
7
20
  ## [3.0.0b7]
8
21
 
9
22
  ### 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.0b8
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.0b8"
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
 
@@ -823,7 +823,20 @@ class SpanMqttClient:
823
823
  for _ in range(_REDISPATCH_RETRY_ATTEMPTS):
824
824
  try:
825
825
  return await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client)
826
- except (SpanPanelConnectionError, SpanPanelTimeoutError) as exc:
826
+ except (
827
+ SpanPanelConnectionError,
828
+ 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.
838
+ SpanPanelServerError,
839
+ ) as exc:
827
840
  last = exc
828
841
  await asyncio.sleep(delay)
829
842
  delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S)
@@ -862,6 +875,23 @@ class SpanMqttClient:
862
875
  """
863
876
  try:
864
877
  await self._redispatch_once()
878
+ except Exception: # pylint: disable=broad-exception-caught
879
+ # Nothing may escape here. This runs as a fire-and-forget task, so an
880
+ # escaping exception becomes "Task exception was never retrieved" in
881
+ # the log and the parser silently stays on the old generation --
882
+ # which is the failure this whole method exists to prevent, arrived at
883
+ # by a different route. That is not hypothetical: a 502 from a
884
+ # rebooting panel did exactly this on two live installs.
885
+ #
886
+ # Logged at ERROR with the consequence spelled out, because the user's
887
+ # remedy is a reload and nothing else will tell them so.
888
+ _LOGGER.error(
889
+ "Could not follow the panel's schema-generation change; the %r parser is "
890
+ "unchanged and its data will read as missing. Reload the integration once "
891
+ "the panel is fully back up.",
892
+ self._data_model_version,
893
+ exc_info=True,
894
+ )
865
895
  finally:
866
896
  # Released only when the swap is finished, not when the fetch is.
867
897
  # 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,7 +30,7 @@ from unittest.mock import patch
30
30
 
31
31
  import pytest
32
32
 
33
- from span_panel_api.exceptions import SpanPanelConnectionError
33
+ from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelServerError
34
34
  from span_panel_api.mqtt.client import _REDISPATCH_RETRY_ATTEMPTS, SpanMqttClient
35
35
  from span_panel_api.mqtt.models import MqttClientConfig
36
36
 
@@ -299,3 +299,68 @@ async def test_a_raising_consumer_does_not_break_the_swap() -> None:
299
299
 
300
300
  assert client.data_model_version == "1.0", "the swap must stand"
301
301
  assert reached == ["second"], "one raising subscriber must not starve the others"
302
+
303
+
304
+ @pytest.mark.asyncio
305
+ async def test_a_rebooting_panel_answering_502_is_waited_for_not_abandoned() -> None:
306
+ """The failure that cost a live firmware upgrade its automatic reload.
307
+
308
+ A panel accepts MQTT before it serves HTTP, and the retry loop above exists
309
+ for that. But there are three ways HTTP lags the broker, and this loop
310
+ originally handled two: it caught "cannot reach" and "timed out" and not
311
+ "answered, with 502". A booting device brings its network stack and reverse
312
+ proxy up before the application behind them, so 502 is the *ordinary* shape,
313
+ not an exotic one.
314
+
315
+ Because `SpanPanelServerError` was not caught, the very first attempt raised
316
+ straight out of the loop, out of the fire-and-forget task that called it, and
317
+ the parser was never swapped. Observed on two Home Assistant instances
318
+ watching one panel through the same upgrade: both logged `Task exception was
319
+ never retrieved`, both stayed on the flat parser, and neither recovered
320
+ without a manual reload.
321
+ """
322
+ client, _ = _client(None)
323
+ before = client.adapter
324
+ attempts = 0
325
+
326
+ def _five_oh_two_then_ready(*_a: object, **_k: object) -> _Schema:
327
+ nonlocal attempts
328
+ attempts += 1
329
+ if attempts < 3:
330
+ raise SpanPanelServerError("Panel not ready: HTTP 502 fetching the Homie schema", 502)
331
+ return _Schema("1.0")
332
+
333
+ with (
334
+ patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_five_oh_two_then_ready),
335
+ patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 0),
336
+ patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 0),
337
+ ):
338
+ await _panel_publishes_version(client, "1.0")
339
+
340
+ assert attempts >= 3, "a 502 must be retried rather than ending the attempt"
341
+ assert client.adapter is not before, "the parser must swap once the panel answers"
342
+
343
+
344
+ @pytest.mark.asyncio
345
+ async def test_an_unexpected_failure_leaves_a_usable_message_rather_than_a_bare_traceback(
346
+ caplog: pytest.LogCaptureFixture,
347
+ ) -> None:
348
+ """Nothing may escape the fire-and-forget task.
349
+
350
+ An escaping exception surfaces as "Task exception was never retrieved" and
351
+ the parser silently stays on the old generation -- the exact failure this
352
+ method exists to prevent, reached by a different route. The user's remedy is
353
+ a reload, and nothing else is going to tell them so.
354
+ """
355
+ client, _ = _client(None)
356
+ before = client.adapter
357
+
358
+ with patch(
359
+ "span_panel_api.mqtt.client.get_homie_schema",
360
+ side_effect=RuntimeError("something nobody predicted"),
361
+ ):
362
+ await _panel_publishes_version(client, "1.0")
363
+
364
+ assert client.adapter is before
365
+ assert "Reload the integration" in caplog.text
366
+ assert "something nobody predicted" in caplog.text
@@ -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