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