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.
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/CHANGELOG.md +13 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/PKG-INFO +1 -1
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/pyproject.toml +1 -1
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/auth.py +22 -2
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/client.py +31 -1
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_auth_and_homie_helpers.py +44 -2
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_redispatch_on_reconnect.py +66 -1
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_conformance.py +51 -12
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/.gitignore +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/LICENSE +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/README.md +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/__init__.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/models.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/reference_payloads/README.md +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/reference_payloads/homie_schema.json +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/conftest.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_adoption.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_mqtt_homie.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_packaging.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_public_api_unchanged.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_adapter.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_against_simulator.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_discovery.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_panel.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_snapshot.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_zero_adapter.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
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(
|
|
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 (
|
|
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
|
|
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
|
-
"""
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
|
594
|
-
"
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/reference_payloads/README.md
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/src/span_panel_api/reference_payloads/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/fixtures/panelbench_unvalued_by_both.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/circuits.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/panel.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/soe.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/status.response.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_generation_cross_check.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_against_simulator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b7 → span_panel_api-3.0.0b8}/tests/test_schema_one_connection_health.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|