span-panel-api 3.0.0b8__tar.gz → 3.0.0b10__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.0b8 → span_panel_api-3.0.0b10}/CHANGELOG.md +31 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/PKG-INFO +1 -1
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/pyproject.toml +1 -1
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/auth.py +28 -3
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/exceptions.py +7 -1
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/mqtt/client.py +54 -26
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_auth_and_homie_helpers.py +52 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_redispatch_on_reconnect.py +127 -11
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/.gitignore +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/LICENSE +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/README.md +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/__init__.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/models.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/reference_payloads/README.md +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/reference_payloads/homie_schema.json +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/conftest.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_adoption.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_mqtt_homie.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_packaging.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_public_api_unchanged.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_adapter.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_against_simulator.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_discovery.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_panel.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_snapshot.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_shared_http_client.py +0 -0
|
@@ -4,6 +4,37 @@ 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.0b10]
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **The wait for a panel to finish rebooting no longer gives up.** It used to stop after a fixed number of attempts, and that bound was wrong twice for the same reason: it was sized against a reboot somebody had measured, and the next reboot was not that
|
|
12
|
+
reboot. Giving up has nothing to recommend it — the only things that start another attempt are the reconnect edge and the panel republishing its data-model version, and a panel that finishes booting after the wait expired produces neither, so running out
|
|
13
|
+
of attempts means stranded until somebody reloads by hand. It now waits as long as the panel takes.
|
|
14
|
+
- **Waiting costs nothing you were relying on.** Energy sensors already hold their last reading through an outage on their own grace period — fifteen minutes by default, configurable — which exists precisely so a gap does not become an `unknown` and a
|
|
15
|
+
statistics spike. That is untouched by how long this waits, and it was the only thing that would have justified a deadline. What is left is one request every thirty seconds to a device on your own network.
|
|
16
|
+
- **The retry interval settles at thirty seconds rather than growing.** Backing off without a ceiling would mean a panel that took a while to return was then ignored for longer than it took. The gap goes 1, 2, 4, 8, 16, 30 and stays there, so once your
|
|
17
|
+
panel is answering it is noticed within half a minute however long the wait has already run.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **Four more ways a booting panel answers now count as "not ready" rather than as a hard failure.** b8 and b9 covered the 502 that a live upgrade produced; review found the fix had covered the observed shape rather than the class. A panel resetting its
|
|
22
|
+
listener mid-request raises `ReadError` or `WriteError`, a proxy that dies mid-request raises `RemoteProtocolError`, and a panel part-way through starting can answer `200` with a truncated or empty body. All four escaped untranslated, skipped the retry
|
|
23
|
+
entirely, and stranded the parser exactly as the 502 did. Transport failures are now `SpanPanelConnectionError` and an unusable body is `SpanPanelServerError`.
|
|
24
|
+
- **The last retry attempt happens after the reboot it is sized for.** The window ended with a sleep that no attempt followed: it read 241 seconds while the final request went out at 211, so a panel ready at 220 was still abandoned. The loop no longer
|
|
25
|
+
sleeps after its final attempt — which also stopped it holding the in-flight guard, and the warning, for a pointless extra backoff — and the last request now lands at 241 seconds. The test asserts that offset instead of summing the sleeps, which was
|
|
26
|
+
restating the implementation's own off-by-one.
|
|
27
|
+
- **The give-up warning no longer promises a recovery that cannot arrive.** It said data would read as missing "until the next reconnect". The triggers are the reconnect edge and the retained `data-model-version` message, and a panel that finishes booting
|
|
28
|
+
produces neither again, so exhausting the window means stuck until a reload. It now says so.
|
|
29
|
+
|
|
30
|
+
## [3.0.0b9]
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **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
|
|
35
|
+
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
|
|
36
|
+
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.
|
|
37
|
+
|
|
7
38
|
## [3.0.0b8]
|
|
8
39
|
|
|
9
40
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.0b10
|
|
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
|
|
@@ -188,10 +188,18 @@ async def get_homie_schema(
|
|
|
188
188
|
try:
|
|
189
189
|
async with _get_client(httpx_client, timeout) as client:
|
|
190
190
|
response = await client.get(url)
|
|
191
|
-
except httpx.ConnectError as exc:
|
|
192
|
-
raise SpanPanelConnectionError(f"Cannot reach panel at {host}") from exc
|
|
193
191
|
except httpx.TimeoutException as exc:
|
|
194
192
|
raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc
|
|
193
|
+
except httpx.TransportError as exc:
|
|
194
|
+
# Every way the connection itself can fail, not just a refused connect:
|
|
195
|
+
# `ReadError` and `WriteError` when a rebooting panel resets mid-request,
|
|
196
|
+
# and `RemoteProtocolError` when its proxy closes without answering --
|
|
197
|
+
# which is exactly what a proxy restarting under load produces. Catching
|
|
198
|
+
# only `ConnectError` meant those escaped this function untranslated,
|
|
199
|
+
# skipped the caller's retry clause entirely, and stranded the parser the
|
|
200
|
+
# same way a 502 used to. `TimeoutException` is itself a `TransportError`,
|
|
201
|
+
# so it has to be caught first.
|
|
202
|
+
raise SpanPanelConnectionError(f"Cannot reach panel at {host}: {exc}") from exc
|
|
195
203
|
|
|
196
204
|
if response.status_code >= 500:
|
|
197
205
|
# A rebooting panel answers 502 from its front end while the application
|
|
@@ -210,7 +218,24 @@ async def get_homie_schema(
|
|
|
210
218
|
status_code=response.status_code,
|
|
211
219
|
)
|
|
212
220
|
|
|
213
|
-
|
|
221
|
+
try:
|
|
222
|
+
parsed = response.json()
|
|
223
|
+
except ValueError as exc:
|
|
224
|
+
# A panel part-way through starting can answer 200 with a truncated or
|
|
225
|
+
# empty body. Retryable for the same reason a 502 is -- it is "not ready
|
|
226
|
+
# yet" wearing a different status -- and untranslated this had precisely
|
|
227
|
+
# the 502's old character: raised out of the caller's retry loop on the
|
|
228
|
+
# first attempt and left the parser where it was.
|
|
229
|
+
raise SpanPanelServerError(
|
|
230
|
+
f"Panel not ready: {host} answered 200 with a body that is not JSON",
|
|
231
|
+
status_code=response.status_code,
|
|
232
|
+
) from exc
|
|
233
|
+
if not isinstance(parsed, dict):
|
|
234
|
+
raise SpanPanelServerError(
|
|
235
|
+
f"Panel not ready: {host} answered 200 with {type(parsed).__name__}, not an object",
|
|
236
|
+
status_code=response.status_code,
|
|
237
|
+
)
|
|
238
|
+
data: dict[str, object] = parsed
|
|
214
239
|
|
|
215
240
|
# Extract types — each value is a dict of property definitions
|
|
216
241
|
raw_types = data.get("types", {})
|
|
@@ -30,7 +30,13 @@ class SpanPanelAPIError(SpanPanelError):
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
class SpanPanelServerError(SpanPanelAPIError):
|
|
33
|
-
"""
|
|
33
|
+
"""The panel answered, and the answer means "not ready yet".
|
|
34
|
+
|
|
35
|
+
Any 5xx, and a 200 whose body cannot be a schema. Distinct from
|
|
36
|
+
`SpanPanelAPIError` because a caller can retry this and should not retry a
|
|
37
|
+
4xx, which will not fix itself. A rebooting panel produces these for as long
|
|
38
|
+
as its front end is up and the application behind it is not.
|
|
39
|
+
"""
|
|
34
40
|
|
|
35
41
|
|
|
36
42
|
class SpanPanelStaleDataError(SpanPanelError):
|
|
@@ -48,9 +48,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
|
|
52
51
|
_REDISPATCH_RETRY_INITIAL_S = 1.0
|
|
53
|
-
_REDISPATCH_RETRY_MAX_S =
|
|
52
|
+
_REDISPATCH_RETRY_MAX_S = 30.0
|
|
53
|
+
_REDISPATCH_LOG_EVERY = 20
|
|
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]:
|
|
@@ -805,7 +817,7 @@ class SpanMqttClient:
|
|
|
805
817
|
return active != observed
|
|
806
818
|
|
|
807
819
|
async def _fetch_schema_with_retry(self) -> V2HomieSchema | None:
|
|
808
|
-
"""Read the panel's REST schema,
|
|
820
|
+
"""Read the panel's REST schema, waiting for HTTP to catch up with the broker.
|
|
809
821
|
|
|
810
822
|
A panel that has just restarted accepts MQTT before it serves HTTP — the
|
|
811
823
|
broker is listening while the application is still binding its port. The
|
|
@@ -814,40 +826,56 @@ class SpanMqttClient:
|
|
|
814
826
|
was no further edge to retry on, leaving the wrong parser in place for the
|
|
815
827
|
rest of the session.
|
|
816
828
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
829
|
+
**This waits as long as it takes, and that is deliberate.** Every bounded
|
|
830
|
+
version of it has been wrong, twice for the same reason: the bound was
|
|
831
|
+
sized against a reboot somebody had measured, and the next reboot was not
|
|
832
|
+
that reboot. Giving up has no upside to weigh against being wrong. The
|
|
833
|
+
triggers for another attempt are the reconnect edge and the retained
|
|
834
|
+
`data-model-version` message, and a panel that finishes booting after the
|
|
835
|
+
loop gave up produces neither — so exhausting a bound does not mean
|
|
836
|
+
"try again later", it means stranded until somebody reloads by hand.
|
|
837
|
+
|
|
838
|
+
Nor does waiting cost the freshness of anything. Energy sensors already
|
|
839
|
+
hold their last valid reading through an outage on their own grace period,
|
|
840
|
+
which exists precisely so a gap does not become an `unknown` and a
|
|
841
|
+
statistics spike; that mechanism is untouched by how long this waits, and
|
|
842
|
+
it is the thing that would have justified a deadline here. What is left is
|
|
843
|
+
one HTTP GET every thirty seconds to a device on the local network, which
|
|
844
|
+
is less traffic than the ordinary snapshot poll.
|
|
845
|
+
|
|
846
|
+
Ends on success, on cancellation — `close()` cancels this task, so unload
|
|
847
|
+
and shutdown are prompt — or on an error that is not the panel still
|
|
848
|
+
coming up, which is left to raise.
|
|
820
849
|
"""
|
|
821
850
|
delay = _REDISPATCH_RETRY_INITIAL_S
|
|
822
|
-
|
|
823
|
-
|
|
851
|
+
attempts = 0
|
|
852
|
+
while True:
|
|
824
853
|
try:
|
|
825
854
|
return await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client)
|
|
826
855
|
except (
|
|
827
856
|
SpanPanelConnectionError,
|
|
828
857
|
SpanPanelTimeoutError,
|
|
829
|
-
# The
|
|
830
|
-
#
|
|
831
|
-
#
|
|
832
|
-
#
|
|
833
|
-
#
|
|
834
|
-
#
|
|
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.
|
|
858
|
+
# The panel answering rather than refusing: a 5xx from its front
|
|
859
|
+
# end while the application behind it starts, or a 200 carrying a
|
|
860
|
+
# body that cannot be a schema. The ordinary shape of a reboot,
|
|
861
|
+
# because a device brings its network stack and proxy up before
|
|
862
|
+
# its application -- and the shape that stranded two live installs
|
|
863
|
+
# when it was not caught here.
|
|
838
864
|
SpanPanelServerError,
|
|
839
865
|
) as exc:
|
|
840
|
-
|
|
866
|
+
attempts += 1
|
|
867
|
+
if attempts == 1 or attempts % _REDISPATCH_LOG_EVERY == 0:
|
|
868
|
+
# First failure, then occasionally. A panel that never returns
|
|
869
|
+
# would otherwise write a line every thirty seconds forever,
|
|
870
|
+
# and the second line is worth no more than the first.
|
|
871
|
+
_LOGGER.warning(
|
|
872
|
+
"Panel is not serving its schema yet (%s). Attempt %d; still "
|
|
873
|
+
"waiting, and the parser stays as it is until it answers.",
|
|
874
|
+
exc,
|
|
875
|
+
attempts,
|
|
876
|
+
)
|
|
841
877
|
await asyncio.sleep(delay)
|
|
842
878
|
delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S)
|
|
843
|
-
_LOGGER.warning(
|
|
844
|
-
"Could not re-read the panel schema after %d attempts (%s). The active "
|
|
845
|
-
"parser is unchanged; if the panel's schema generation did change, its "
|
|
846
|
-
"data will read as missing until the next reconnect.",
|
|
847
|
-
_REDISPATCH_RETRY_ATTEMPTS,
|
|
848
|
-
last,
|
|
849
|
-
)
|
|
850
|
-
return None
|
|
851
879
|
|
|
852
880
|
async def _redispatch_if_generation_changed(self) -> None:
|
|
853
881
|
"""Swap the parser when the panel comes back as a different schema generation.
|
|
@@ -221,3 +221,55 @@ class TestHttpxClientInjectionAuthHelpers:
|
|
|
221
221
|
|
|
222
222
|
mock_cls.assert_not_called()
|
|
223
223
|
injected.aclose.assert_not_called()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class TestGetHomieSchemaNotReadyShapes:
|
|
227
|
+
"""Every way a booting panel answers that is not a clean 5xx.
|
|
228
|
+
|
|
229
|
+
Each of these used to escape `get_homie_schema` untranslated, skip the
|
|
230
|
+
caller's retry clause entirely, and strand the parser — the same failure the
|
|
231
|
+
502 produced on a live upgrade, wearing a different exception.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
@pytest.mark.asyncio
|
|
235
|
+
@pytest.mark.parametrize(
|
|
236
|
+
"failure",
|
|
237
|
+
[
|
|
238
|
+
httpx.ReadError("connection reset"),
|
|
239
|
+
httpx.WriteError("broken pipe"),
|
|
240
|
+
httpx.RemoteProtocolError("server closed connection without sending a response"),
|
|
241
|
+
],
|
|
242
|
+
ids=["read-reset", "write-reset", "proxy-closed-without-answering"],
|
|
243
|
+
)
|
|
244
|
+
async def test_a_transport_failure_is_a_connection_error(self, failure: Exception) -> None:
|
|
245
|
+
"""A panel resetting its listener mid-request, and a proxy dying mid-request.
|
|
246
|
+
|
|
247
|
+
`httpx.TimeoutException` is itself a `TransportError`, so the timeout
|
|
248
|
+
branch has to stay ahead of this one — covered by the timeout test above.
|
|
249
|
+
"""
|
|
250
|
+
with patch("span_panel_api._http.httpx.AsyncClient") as cls:
|
|
251
|
+
cls.return_value = _mock_client("get", failure)
|
|
252
|
+
with pytest.raises(SpanPanelConnectionError):
|
|
253
|
+
await get_homie_schema("192.168.1.1")
|
|
254
|
+
|
|
255
|
+
@pytest.mark.asyncio
|
|
256
|
+
@pytest.mark.parametrize("body", ["", "{trunc", "null", "[]"], ids=["empty", "truncated", "null", "list"])
|
|
257
|
+
async def test_a_200_that_cannot_be_a_schema_is_not_ready_rather_than_broken(self, body: str) -> None:
|
|
258
|
+
"""A panel part-way through starting can answer 200 with nothing usable.
|
|
259
|
+
|
|
260
|
+
Retryable for the same reason a 502 is: it is "not ready yet" wearing a
|
|
261
|
+
success status. The bounded attempt count makes retrying a genuinely
|
|
262
|
+
broken body cheap.
|
|
263
|
+
"""
|
|
264
|
+
response = MagicMock()
|
|
265
|
+
response.status_code = 200
|
|
266
|
+
response.json = MagicMock(side_effect=(lambda: json.loads(body)) if body else ValueError("no content"))
|
|
267
|
+
mock = AsyncMock()
|
|
268
|
+
mock.get = AsyncMock(return_value=response)
|
|
269
|
+
mock.__aenter__ = AsyncMock(return_value=mock)
|
|
270
|
+
mock.__aexit__ = AsyncMock(return_value=False)
|
|
271
|
+
|
|
272
|
+
with patch("span_panel_api._http.httpx.AsyncClient") as cls:
|
|
273
|
+
cls.return_value = mock
|
|
274
|
+
with pytest.raises(SpanPanelServerError):
|
|
275
|
+
await get_homie_schema("192.168.1.1")
|
|
@@ -31,7 +31,11 @@ from unittest.mock import patch
|
|
|
31
31
|
import pytest
|
|
32
32
|
|
|
33
33
|
from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelServerError
|
|
34
|
-
from span_panel_api.mqtt.client import
|
|
34
|
+
from span_panel_api.mqtt.client import (
|
|
35
|
+
_REDISPATCH_RETRY_INITIAL_S,
|
|
36
|
+
_REDISPATCH_RETRY_MAX_S,
|
|
37
|
+
SpanMqttClient,
|
|
38
|
+
)
|
|
35
39
|
from span_panel_api.mqtt.models import MqttClientConfig
|
|
36
40
|
|
|
37
41
|
from conftest import SERIAL
|
|
@@ -96,7 +100,7 @@ async def _panel_publishes_version(client: SpanMqttClient, version: str | None)
|
|
|
96
100
|
# The refetch is scheduled rather than awaited, so the message callback can stay
|
|
97
101
|
# synchronous. Let the loop drain it.
|
|
98
102
|
# One turn per retry attempt, plus slack for the task itself.
|
|
99
|
-
for _ in range(
|
|
103
|
+
for _ in range(24):
|
|
100
104
|
await asyncio.sleep(0)
|
|
101
105
|
|
|
102
106
|
|
|
@@ -210,13 +214,15 @@ async def test_http_lagging_the_broker_is_retried_not_abandoned() -> None:
|
|
|
210
214
|
|
|
211
215
|
|
|
212
216
|
@pytest.mark.asyncio
|
|
213
|
-
async def
|
|
214
|
-
"""
|
|
215
|
-
|
|
216
|
-
MQTT is up or this path would not be running, so tearing the connection down
|
|
217
|
-
an
|
|
218
|
-
|
|
219
|
-
|
|
217
|
+
async def test_a_panel_that_is_not_serving_http_yet_leaves_the_parser_alone() -> None:
|
|
218
|
+
"""Waiting must not disturb what is already working.
|
|
219
|
+
|
|
220
|
+
MQTT is up or this path would not be running, so tearing the connection down
|
|
221
|
+
over an HTTP endpoint that has not come up would turn a panel that is merely
|
|
222
|
+
booting into a dead integration. The parser stays as it is while the wait
|
|
223
|
+
runs — a stale parser reports missing data rather than wrong data, because
|
|
224
|
+
the two schemas share no topic shape — and the wait keeps going rather than
|
|
225
|
+
giving up, because nothing else will start it again.
|
|
220
226
|
"""
|
|
221
227
|
client, _ = _client(None)
|
|
222
228
|
before = client.adapter
|
|
@@ -231,8 +237,16 @@ async def test_a_panel_that_never_serves_http_leaves_the_parser_alone() -> None:
|
|
|
231
237
|
):
|
|
232
238
|
await _panel_publishes_version(client, "1.0")
|
|
233
239
|
|
|
234
|
-
|
|
235
|
-
|
|
240
|
+
assert client.adapter is before
|
|
241
|
+
assert client._redispatch_in_flight, (
|
|
242
|
+
"the guard is held for as long as the wait runs, so a second edge does not " "start a competing attempt"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Cancelled directly rather than through `close()`, which this fixture's fake
|
|
246
|
+
# bridge cannot service. That the wait ends on cancellation is covered by
|
|
247
|
+
# `test_the_wait_ends_promptly_when_the_client_is_closed`.
|
|
248
|
+
for task in list(client._background_tasks):
|
|
249
|
+
task.cancel()
|
|
236
250
|
|
|
237
251
|
|
|
238
252
|
@pytest.mark.asyncio
|
|
@@ -364,3 +378,105 @@ async def test_an_unexpected_failure_leaves_a_usable_message_rather_than_a_bare_
|
|
|
364
378
|
assert client.adapter is before
|
|
365
379
|
assert "Reload the integration" in caplog.text
|
|
366
380
|
assert "something nobody predicted" in caplog.text
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@pytest.mark.asyncio
|
|
384
|
+
async def test_the_backoff_reaches_a_steady_state_rather_than_growing() -> None:
|
|
385
|
+
"""Once the panel is up, the wait to notice it must stay short.
|
|
386
|
+
|
|
387
|
+
Doubling without a ceiling would mean a panel that took a while to come back
|
|
388
|
+
was then ignored for longer than it took — minutes between attempts by the
|
|
389
|
+
time it is answering. The interval has to settle, so the worst case between
|
|
390
|
+
the panel being ready and this loop finding out is one interval however long
|
|
391
|
+
the wait has already run.
|
|
392
|
+
|
|
393
|
+
**Observed from the loop, not recomputed.** The first version of this test
|
|
394
|
+
calculated the backoff sequence itself and asserted on its own arithmetic,
|
|
395
|
+
which passes just as happily when the ceiling is removed from the code — the
|
|
396
|
+
same mistake as the window test it replaced. These are the sleeps the real
|
|
397
|
+
function performed.
|
|
398
|
+
"""
|
|
399
|
+
client, _ = _client(None)
|
|
400
|
+
slept: list[float] = []
|
|
401
|
+
attempts = 0
|
|
402
|
+
|
|
403
|
+
def _ready_eventually(*_a: object, **_k: object) -> _Schema:
|
|
404
|
+
nonlocal attempts
|
|
405
|
+
attempts += 1
|
|
406
|
+
if attempts < 30:
|
|
407
|
+
raise SpanPanelServerError("Panel not ready: HTTP 502", 502)
|
|
408
|
+
return _Schema("1.0")
|
|
409
|
+
|
|
410
|
+
async def _record(seconds: float) -> None:
|
|
411
|
+
slept.append(seconds)
|
|
412
|
+
|
|
413
|
+
with (
|
|
414
|
+
patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_ready_eventually),
|
|
415
|
+
patch("span_panel_api.mqtt.client.asyncio.sleep", _record),
|
|
416
|
+
):
|
|
417
|
+
assert await client._fetch_schema_with_retry() is not None
|
|
418
|
+
|
|
419
|
+
assert slept[0] == _REDISPATCH_RETRY_INITIAL_S, "it should start responsive"
|
|
420
|
+
assert max(slept) == _REDISPATCH_RETRY_MAX_S, "and never wait longer than the ceiling"
|
|
421
|
+
assert slept[-1] == _REDISPATCH_RETRY_MAX_S, "settling there rather than continuing to grow"
|
|
422
|
+
assert _REDISPATCH_RETRY_MAX_S <= 30.0, (
|
|
423
|
+
"a steady-state gap longer than half a minute is too long to leave a panel " "that is already answering"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@pytest.mark.asyncio
|
|
428
|
+
async def test_the_wait_does_not_end_on_its_own() -> None:
|
|
429
|
+
"""There is no attempt count to exhaust, and that is the point.
|
|
430
|
+
|
|
431
|
+
Every bounded version of this was wrong, twice, for the same reason: the
|
|
432
|
+
bound was sized against a reboot somebody had measured and the next reboot
|
|
433
|
+
was not that reboot. Giving up has nothing to recommend it — the triggers for
|
|
434
|
+
another attempt are the reconnect edge and the retained message, and a panel
|
|
435
|
+
that finishes booting afterwards produces neither, so exhausting a bound
|
|
436
|
+
means stranded until a human reloads.
|
|
437
|
+
"""
|
|
438
|
+
client, _ = _client(None)
|
|
439
|
+
attempts = 0
|
|
440
|
+
|
|
441
|
+
def _ready_far_later(*_a: object, **_k: object) -> _Schema:
|
|
442
|
+
nonlocal attempts
|
|
443
|
+
attempts += 1
|
|
444
|
+
if attempts < 40: # well past any bound this ever had
|
|
445
|
+
raise SpanPanelServerError("Panel not ready: HTTP 502", 502)
|
|
446
|
+
return _Schema("1.0")
|
|
447
|
+
|
|
448
|
+
with (
|
|
449
|
+
patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_ready_far_later),
|
|
450
|
+
patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 0),
|
|
451
|
+
patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 0),
|
|
452
|
+
):
|
|
453
|
+
assert await client._fetch_schema_with_retry() is not None
|
|
454
|
+
|
|
455
|
+
assert attempts == 40
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@pytest.mark.asyncio
|
|
459
|
+
async def test_the_wait_ends_promptly_when_the_client_is_closed() -> None:
|
|
460
|
+
"""Unbounded is only safe because cancellation is prompt.
|
|
461
|
+
|
|
462
|
+
`close()` cancels every background task, and the cancellation lands inside
|
|
463
|
+
the sleep. Without this, waiting forever would mean a Home Assistant
|
|
464
|
+
shutdown or a config-entry unload waiting with it.
|
|
465
|
+
"""
|
|
466
|
+
client, _ = _client(None)
|
|
467
|
+
|
|
468
|
+
with (
|
|
469
|
+
patch(
|
|
470
|
+
"span_panel_api.mqtt.client.get_homie_schema",
|
|
471
|
+
side_effect=SpanPanelServerError("Panel not ready: HTTP 502", 502),
|
|
472
|
+
),
|
|
473
|
+
patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 3600),
|
|
474
|
+
patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 3600),
|
|
475
|
+
):
|
|
476
|
+
task = asyncio.create_task(client._fetch_schema_with_retry())
|
|
477
|
+
await asyncio.sleep(0)
|
|
478
|
+
task.cancel()
|
|
479
|
+
with pytest.raises(asyncio.CancelledError):
|
|
480
|
+
await task
|
|
481
|
+
|
|
482
|
+
assert task.cancelled()
|
|
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.0b8 → span_panel_api-3.0.0b10}/src/span_panel_api/reference_payloads/README.md
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/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.0b8 → span_panel_api-3.0.0b10}/tests/fixtures/panelbench_unvalued_by_both.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/simulation_fixtures/circuits.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/simulation_fixtures/panel.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/simulation_fixtures/soe.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/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.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_generation_cross_check.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_against_simulator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_connection_health.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b8 → span_panel_api-3.0.0b10}/tests/test_schema_one_service_entrance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|