pybluetti 0.2.2__tar.gz → 0.2.3__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.
- {pybluetti-0.2.2 → pybluetti-0.2.3}/CHANGELOG.md +4 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/PKG-INFO +1 -1
- {pybluetti-0.2.2 → pybluetti-0.2.3}/pyproject.toml +1 -1
- {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/websocket.py +37 -5
- {pybluetti-0.2.2 → pybluetti-0.2.3}/tests/test_websocket.py +53 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/.github/workflows/publish.yml +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/.github/workflows/tests.yml +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/.gitignore +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/LICENSE +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/README.md +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/scripts/lint +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/scripts/setup +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/scripts/test +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/scripts/typecheck +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/__init__.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/client.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/const.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/exceptions.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/models.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/product_client.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/py.typed +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/unify_response.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/tests/__init__.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/tests/test_const.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/tests/test_exceptions.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/tests/test_package.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/tests/test_product_client.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.3}/tests/test_unify_response.py +0 -0
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# 0.2.3
|
|
2
|
+
|
|
3
|
+
- `StompClient` now stops retrying (instead of reconnecting forever, every ~30s with backoff) once the cloud sends msgCode 400, 403, or 600 in an ERROR frame - these are confirmed (600, directly observed retried unsuccessfully for over a day against a real device) or strongly implied (400, 403, per BLUETTI's own official Home Assistant integration's client, which groups all three with a genuine token expiry - msgCode 805 - as needing the same "stop and don't retry" response) to never succeed on retry. Deliberately not folded into the existing `on_auth_expired` (805) path: none of these three necessarily mean the access token itself is the problem, so claiming that would be misleading - they still reach a caller via `on_error`, with the real message, same as any other non-805 code.
|
|
4
|
+
|
|
1
5
|
# 0.2.2
|
|
2
6
|
|
|
3
7
|
- `ApplicationRuntimeException`'s `msgCode` is now included in `str(exc)` itself (e.g. `[500] server error`), not just the separate `.msgCode` attribute - every existing caller that logs or displays this exception (notably `bluetti-home-assistant`'s own websocket-error Repair issue, which shows `str(err)` directly to the end user) now surfaces the code with no changes needed on its end. Prompted by a real user hitting an unrecognized code with no way to report which one it was short of digging through a raw STOMP frame dump (bluetti-community/bluetti-home-assistant#35). `.message` is unchanged - still the plain, code-free text.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pybluetti
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Async Python client for the BLUETTI cloud API - device discovery, state, and control.
|
|
5
5
|
Project-URL: Homepage, https://github.com/bluetti-community/pybluetti
|
|
6
6
|
Project-URL: Used by, https://github.com/bluetti-community/bluetti-home-assistant
|
|
@@ -19,6 +19,22 @@ from .exceptions import ApplicationRuntimeException
|
|
|
19
19
|
|
|
20
20
|
__LOGGER__ = logging.getLogger(__name__)
|
|
21
21
|
|
|
22
|
+
# ERROR frame msgCodes that mean this connection can never succeed as-is -
|
|
23
|
+
# retrying it is pointless, not just unlikely. 600 ("Upgrade required, and
|
|
24
|
+
# then reconfigure the BLUETTI integration") is directly confirmed against
|
|
25
|
+
# a real device: retried every ~30s for over a day with the identical
|
|
26
|
+
# rejection every time (bluetti-community/bluetti-home-assistant#35). 400
|
|
27
|
+
# and 403 aren't yet directly observed here, but BLUETTI's own official
|
|
28
|
+
# client (bluetti-official/bluetti-home-assistant's api/websocket.py)
|
|
29
|
+
# groups all three with 805 (a genuinely expired token) as needing the same
|
|
30
|
+
# response - stop and don't retry. They're kept out of on_auth_expired's
|
|
31
|
+
# 805 path below deliberately: unlike 805, none of these three necessarily
|
|
32
|
+
# mean the access token itself is the problem (600's own real cause is
|
|
33
|
+
# still unconfirmed - see the issue above), so claiming that would be
|
|
34
|
+
# actively misleading. They still reach a caller via on_error, same as any
|
|
35
|
+
# other non-805 code - this only stops the pointless retry loop.
|
|
36
|
+
_TERMINAL_ERROR_CODES = frozenset({400, 403, 600})
|
|
37
|
+
|
|
22
38
|
|
|
23
39
|
class StompClient:
|
|
24
40
|
"""A STOMP client connected to the BLUETTI cloud's push-update websocket."""
|
|
@@ -43,11 +59,15 @@ class StompClient:
|
|
|
43
59
|
- on_auth_expired: called when the cloud reports the access token as
|
|
44
60
|
expired (msgCode 805), so the caller can react.
|
|
45
61
|
- on_error: called with any other ERROR frame the cloud sends back
|
|
46
|
-
(a msgCode other than 805). The client
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
(a msgCode other than 805). The client keeps retrying with
|
|
63
|
+
backoff for most of these - some are transient - except a known
|
|
64
|
+
set (see _TERMINAL_ERROR_CODES) it stops retrying for, since
|
|
65
|
+
those are confirmed (or, per BLUETTI's own official client,
|
|
66
|
+
strongly implied) to never succeed on retry either. Either way,
|
|
67
|
+
nothing else surfaces a persistent one distinctly from a
|
|
68
|
+
run-of-the-mill connection drop, so a caller that wants to react
|
|
69
|
+
(log once, show the user something actionable) has no other hook
|
|
70
|
+
for it.
|
|
51
71
|
"""
|
|
52
72
|
self._session = session
|
|
53
73
|
self.__url = url + "/websocket"
|
|
@@ -259,6 +279,18 @@ class StompClient:
|
|
|
259
279
|
self.on_auth_expired()
|
|
260
280
|
__LOGGER__.info("token have expired stop ws connect")
|
|
261
281
|
else:
|
|
282
|
+
if error["msgCode"] in _TERMINAL_ERROR_CODES:
|
|
283
|
+
# Same "stop, don't retry" outcome as 805 above, reached
|
|
284
|
+
# the same way _run() already stops retrying on any other
|
|
285
|
+
# exit from its receive loop: setting running False here,
|
|
286
|
+
# before raising, means the "if self.running: reconnect()"
|
|
287
|
+
# check it does afterwards is already False by the time it
|
|
288
|
+
# runs. Deliberately not the 805 branch above - on_error
|
|
289
|
+
# (below, via the raise) still fires with the real message,
|
|
290
|
+
# instead of on_auth_expired's specifically-token-expired
|
|
291
|
+
# framing, which wouldn't be accurate here (see
|
|
292
|
+
# _TERMINAL_ERROR_CODES's own comment).
|
|
293
|
+
self.running = False
|
|
262
294
|
raise ApplicationRuntimeException(msgCode=error["msgCode"], errMessage=error["message"])
|
|
263
295
|
|
|
264
296
|
async def _handle_connected_frame(self, frame: stomper.Frame) -> None:
|
|
@@ -351,6 +351,24 @@ async def test_run_catches_application_runtime_exception_logs_full_and_calls_on_
|
|
|
351
351
|
client.reconnect.assert_awaited_once()
|
|
352
352
|
|
|
353
353
|
|
|
354
|
+
async def test_run_does_not_reconnect_after_a_terminal_error_code():
|
|
355
|
+
ws = _FakeWebSocket([_error_message(600, "Upgrade required")])
|
|
356
|
+
on_error = MagicMock()
|
|
357
|
+
session = _FakeSession(ws)
|
|
358
|
+
client = StompClient(session, GATEWAY_WS_URL, "token", on_error=on_error)
|
|
359
|
+
client._ws = ws
|
|
360
|
+
client.running = True
|
|
361
|
+
client.reconnect = AsyncMock()
|
|
362
|
+
|
|
363
|
+
await client._run()
|
|
364
|
+
|
|
365
|
+
on_error.assert_called_once()
|
|
366
|
+
assert on_error.call_args[0][0].msgCode == 600
|
|
367
|
+
assert ws.close_called is True
|
|
368
|
+
client.reconnect.assert_not_awaited()
|
|
369
|
+
assert client.running is False
|
|
370
|
+
|
|
371
|
+
|
|
354
372
|
async def test_run_downgrades_repeated_identical_application_runtime_exception():
|
|
355
373
|
ws = _FakeWebSocket([_error_message(500, "server error")])
|
|
356
374
|
session = _FakeSession(ws)
|
|
@@ -442,6 +460,41 @@ async def test_handle_frame_error_other_code_raises():
|
|
|
442
460
|
assert exc_info.value.msgCode == 500
|
|
443
461
|
|
|
444
462
|
|
|
463
|
+
async def test_handle_frame_error_other_code_does_not_stop_retrying():
|
|
464
|
+
# A non-terminal, non-805 code (500) must not be mistaken for one of
|
|
465
|
+
# the codes known to never succeed on retry - client.running is left
|
|
466
|
+
# exactly as it was (True: a real, live connection still exists).
|
|
467
|
+
client, _session, _on_auth_expired = _client()
|
|
468
|
+
client.running = True
|
|
469
|
+
payload = json.dumps({"msgCode": 500, "message": "server error"}).replace(":", "\\c")
|
|
470
|
+
raw = f"ERROR\nmessage:{payload}\n\n\x00"
|
|
471
|
+
|
|
472
|
+
with pytest.raises(ApplicationRuntimeException):
|
|
473
|
+
await client._handle_frame(raw)
|
|
474
|
+
|
|
475
|
+
assert client.running is True
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@pytest.mark.parametrize("msg_code", [400, 403, 600])
|
|
479
|
+
async def test_handle_frame_error_terminal_code_stops_retrying_but_still_raises(msg_code):
|
|
480
|
+
# 400/403/600: confirmed (600) or strongly implied (400, 403 - see
|
|
481
|
+
# _TERMINAL_ERROR_CODES's own comment) to never succeed on retry.
|
|
482
|
+
# Unlike 805, these still raise (on_error fires with the real message)
|
|
483
|
+
# rather than calling on_auth_expired - none of the three necessarily
|
|
484
|
+
# mean the token itself is the problem.
|
|
485
|
+
client, _session, on_auth_expired = _client()
|
|
486
|
+
client.running = True
|
|
487
|
+
payload = json.dumps({"msgCode": msg_code, "message": "terminal"}).replace(":", "\\c")
|
|
488
|
+
raw = f"ERROR\nmessage:{payload}\n\n\x00"
|
|
489
|
+
|
|
490
|
+
with pytest.raises(ApplicationRuntimeException) as exc_info:
|
|
491
|
+
await client._handle_frame(raw)
|
|
492
|
+
|
|
493
|
+
assert exc_info.value.msgCode == msg_code
|
|
494
|
+
assert client.running is False
|
|
495
|
+
on_auth_expired.assert_not_called()
|
|
496
|
+
|
|
497
|
+
|
|
445
498
|
async def test_handle_frame_connected_without_websocket_logs_and_returns():
|
|
446
499
|
client, _session, _on_auth_expired = _client()
|
|
447
500
|
raw = "CONNECTED\nheart-beat:10000,10000\nuser-name:bob\n\n\x00"
|
|
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
|