span-panel-api 3.0.0b9__tar.gz → 3.0.0b11__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.0b9 → span_panel_api-3.0.0b11}/CHANGELOG.md +27 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/PKG-INFO +1 -1
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/pyproject.toml +1 -1
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/auth.py +28 -3
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/exceptions.py +7 -1
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/mqtt/client.py +41 -25
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_auth_and_homie_helpers.py +52 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_redispatch_on_reconnect.py +117 -32
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_panel.py +33 -1
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/.gitignore +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/LICENSE +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/README.md +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/__init__.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/models.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/reference_payloads/README.md +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/reference_payloads/homie_schema.json +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/conftest.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_adoption.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_mqtt_homie.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_packaging.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_public_api_unchanged.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_adapter.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_against_simulator.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_discovery.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_snapshot.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/test_shared_http_client.py +0 -0
|
@@ -4,6 +4,33 @@ 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.0b11]
|
|
8
|
+
|
|
9
|
+
Carries `span-panel-api-schema-1` 0.1.0b8, which restores `dominant_power_source` on a panel with no MID. No change in this distribution.
|
|
10
|
+
|
|
11
|
+
## [3.0.0b10]
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **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
|
|
16
|
+
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
|
|
17
|
+
of attempts means stranded until somebody reloads by hand. It now waits as long as the panel takes.
|
|
18
|
+
- **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
|
|
19
|
+
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.
|
|
20
|
+
- **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
|
|
21
|
+
panel is answering it is noticed within half a minute however long the wait has already run.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **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
|
|
26
|
+
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
|
|
27
|
+
entirely, and stranded the parser exactly as the 502 did. Transport failures are now `SpanPanelConnectionError` and an unusable body is `SpanPanelServerError`.
|
|
28
|
+
- **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
|
|
29
|
+
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
|
|
30
|
+
restating the implementation's own off-by-one.
|
|
31
|
+
- **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
|
|
32
|
+
produces neither again, so exhausting the window means stuck until a reload. It now says so.
|
|
33
|
+
|
|
7
34
|
## [3.0.0b9]
|
|
8
35
|
|
|
9
36
|
### 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.0b11
|
|
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,9 @@ _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 = 12
|
|
52
51
|
_REDISPATCH_RETRY_INITIAL_S = 1.0
|
|
53
52
|
_REDISPATCH_RETRY_MAX_S = 30.0
|
|
53
|
+
_REDISPATCH_LOG_EVERY = 20
|
|
54
54
|
"""How long to wait for the panel's HTTP endpoint after it returns on MQTT.
|
|
55
55
|
|
|
56
56
|
Sized from a live firmware upgrade rather than guessed. The panel dropped MQTT at
|
|
@@ -817,7 +817,7 @@ class SpanMqttClient:
|
|
|
817
817
|
return active != observed
|
|
818
818
|
|
|
819
819
|
async def _fetch_schema_with_retry(self) -> V2HomieSchema | None:
|
|
820
|
-
"""Read the panel's REST schema,
|
|
820
|
+
"""Read the panel's REST schema, waiting for HTTP to catch up with the broker.
|
|
821
821
|
|
|
822
822
|
A panel that has just restarted accepts MQTT before it serves HTTP — the
|
|
823
823
|
broker is listening while the application is still binding its port. The
|
|
@@ -826,40 +826,56 @@ class SpanMqttClient:
|
|
|
826
826
|
was no further edge to retry on, leaving the wrong parser in place for the
|
|
827
827
|
rest of the session.
|
|
828
828
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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.
|
|
832
849
|
"""
|
|
833
850
|
delay = _REDISPATCH_RETRY_INITIAL_S
|
|
834
|
-
|
|
835
|
-
|
|
851
|
+
attempts = 0
|
|
852
|
+
while True:
|
|
836
853
|
try:
|
|
837
854
|
return await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client)
|
|
838
855
|
except (
|
|
839
856
|
SpanPanelConnectionError,
|
|
840
857
|
SpanPanelTimeoutError,
|
|
841
|
-
# The
|
|
842
|
-
#
|
|
843
|
-
#
|
|
844
|
-
#
|
|
845
|
-
#
|
|
846
|
-
#
|
|
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.
|
|
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.
|
|
850
864
|
SpanPanelServerError,
|
|
851
865
|
) as exc:
|
|
852
|
-
|
|
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
|
+
)
|
|
853
877
|
await asyncio.sleep(delay)
|
|
854
878
|
delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S)
|
|
855
|
-
_LOGGER.warning(
|
|
856
|
-
"Could not re-read the panel schema after %d attempts (%s). The active "
|
|
857
|
-
"parser is unchanged; if the panel's schema generation did change, its "
|
|
858
|
-
"data will read as missing until the next reconnect.",
|
|
859
|
-
_REDISPATCH_RETRY_ATTEMPTS,
|
|
860
|
-
last,
|
|
861
|
-
)
|
|
862
|
-
return None
|
|
863
879
|
|
|
864
880
|
async def _redispatch_if_generation_changed(self) -> None:
|
|
865
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")
|
|
@@ -32,7 +32,6 @@ import pytest
|
|
|
32
32
|
|
|
33
33
|
from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelServerError
|
|
34
34
|
from span_panel_api.mqtt.client import (
|
|
35
|
-
_REDISPATCH_RETRY_ATTEMPTS,
|
|
36
35
|
_REDISPATCH_RETRY_INITIAL_S,
|
|
37
36
|
_REDISPATCH_RETRY_MAX_S,
|
|
38
37
|
SpanMqttClient,
|
|
@@ -101,7 +100,7 @@ async def _panel_publishes_version(client: SpanMqttClient, version: str | None)
|
|
|
101
100
|
# The refetch is scheduled rather than awaited, so the message callback can stay
|
|
102
101
|
# synchronous. Let the loop drain it.
|
|
103
102
|
# One turn per retry attempt, plus slack for the task itself.
|
|
104
|
-
for _ in range(
|
|
103
|
+
for _ in range(24):
|
|
105
104
|
await asyncio.sleep(0)
|
|
106
105
|
|
|
107
106
|
|
|
@@ -215,13 +214,15 @@ async def test_http_lagging_the_broker_is_retried_not_abandoned() -> None:
|
|
|
215
214
|
|
|
216
215
|
|
|
217
216
|
@pytest.mark.asyncio
|
|
218
|
-
async def
|
|
219
|
-
"""
|
|
220
|
-
|
|
221
|
-
MQTT is up or this path would not be running, so tearing the connection down
|
|
222
|
-
an
|
|
223
|
-
|
|
224
|
-
|
|
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.
|
|
225
226
|
"""
|
|
226
227
|
client, _ = _client(None)
|
|
227
228
|
before = client.adapter
|
|
@@ -236,8 +237,16 @@ async def test_a_panel_that_never_serves_http_leaves_the_parser_alone() -> None:
|
|
|
236
237
|
):
|
|
237
238
|
await _panel_publishes_version(client, "1.0")
|
|
238
239
|
|
|
239
|
-
|
|
240
|
-
|
|
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()
|
|
241
250
|
|
|
242
251
|
|
|
243
252
|
@pytest.mark.asyncio
|
|
@@ -371,27 +380,103 @@ async def test_an_unexpected_failure_leaves_a_usable_message_rather_than_a_bare_
|
|
|
371
380
|
assert "something nobody predicted" in caplog.text
|
|
372
381
|
|
|
373
382
|
|
|
374
|
-
|
|
375
|
-
|
|
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")
|
|
376
409
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
HTTP front end was still answering 502 at that moment, which is when this
|
|
380
|
-
loop starts.
|
|
410
|
+
async def _record(seconds: float) -> None:
|
|
411
|
+
slept.append(seconds)
|
|
381
412
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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"
|
|
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"
|
|
397
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()
|
|
@@ -445,7 +445,39 @@ def test_an_unresolvable_forming_entity_cannot_escape_as_a_raw_id() -> None:
|
|
|
445
445
|
|
|
446
446
|
assert resolve_dominant_power_source(stranger, {}) == "UNKNOWN"
|
|
447
447
|
assert resolve_dominant_power_source(unmapped, {"wh-1": "energy.ebus.device.water-heater"}) == "UNKNOWN"
|
|
448
|
-
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def test_a_panel_with_no_mid_reports_the_grid_as_forming() -> None:
|
|
451
|
+
"""Elimination, not a guess, and it restores an answer flat already gave.
|
|
452
|
+
|
|
453
|
+
A commissioned MID is what SPAN islands with, so its absence rules out every
|
|
454
|
+
other value this field can take. `BATTERY` needs a BESS and a BESS brings a
|
|
455
|
+
MID; `PV` cannot form a grid alone, because anything that can is a
|
|
456
|
+
grid-forming inverter and therefore a MID; `NONE` describes a panel supplying
|
|
457
|
+
nothing, which is a panel that is not publishing. What remains is a generator,
|
|
458
|
+
and that is two cases of which only one reaches here — one wired through a MID
|
|
459
|
+
is named by that MID and answered before this point, while one with no MID
|
|
460
|
+
interface is what SPAN treats as the grid, and is the only kind an install
|
|
461
|
+
with no MID can have. So this keeps holding if MID-integrated generators
|
|
462
|
+
arrive: they bring a MID.
|
|
463
|
+
|
|
464
|
+
Observed: a live no-BESS panel read `Grid` on flat all night and went
|
|
465
|
+
`Unknown` the moment it upgraded, because the property moved onto a device
|
|
466
|
+
that install does not have. Nothing about the site changed.
|
|
467
|
+
"""
|
|
468
|
+
assert resolve_dominant_power_source(None, {}) == "GRID"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def test_a_mid_that_has_not_answered_is_unknown_rather_than_grid() -> None:
|
|
472
|
+
"""Distinct from having no MID at all, and the distinction is the whole point.
|
|
473
|
+
|
|
474
|
+
An islanding authority exists and has not said what is forming the grid. That
|
|
475
|
+
is genuinely unknown — unlike an install with no such authority, where the
|
|
476
|
+
answer is settled by what cannot be there.
|
|
477
|
+
"""
|
|
478
|
+
silent = _synthetic("mid", grid__grid_forming_entity="")
|
|
479
|
+
|
|
480
|
+
assert resolve_dominant_power_source(silent, {}) is None
|
|
449
481
|
|
|
450
482
|
|
|
451
483
|
def test_the_forming_device_is_named_readably_not_by_wire_id() -> None:
|
|
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.0b9 → span_panel_api-3.0.0b11}/src/span_panel_api/reference_payloads/README.md
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/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.0b9 → span_panel_api-3.0.0b11}/tests/fixtures/panelbench_unvalued_by_both.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/simulation_fixtures/circuits.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/simulation_fixtures/panel.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/tests/simulation_fixtures/soe.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/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.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_generation_cross_check.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/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.0b9 → span_panel_api-3.0.0b11}/tests/test_schema_one_connection_health.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b9 → span_panel_api-3.0.0b11}/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
|