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.
Files changed (28) hide show
  1. {pybluetti-0.2.2 → pybluetti-0.2.3}/CHANGELOG.md +4 -0
  2. {pybluetti-0.2.2 → pybluetti-0.2.3}/PKG-INFO +1 -1
  3. {pybluetti-0.2.2 → pybluetti-0.2.3}/pyproject.toml +1 -1
  4. {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/websocket.py +37 -5
  5. {pybluetti-0.2.2 → pybluetti-0.2.3}/tests/test_websocket.py +53 -0
  6. {pybluetti-0.2.2 → pybluetti-0.2.3}/.github/workflows/publish.yml +0 -0
  7. {pybluetti-0.2.2 → pybluetti-0.2.3}/.github/workflows/tests.yml +0 -0
  8. {pybluetti-0.2.2 → pybluetti-0.2.3}/.gitignore +0 -0
  9. {pybluetti-0.2.2 → pybluetti-0.2.3}/LICENSE +0 -0
  10. {pybluetti-0.2.2 → pybluetti-0.2.3}/README.md +0 -0
  11. {pybluetti-0.2.2 → pybluetti-0.2.3}/scripts/lint +0 -0
  12. {pybluetti-0.2.2 → pybluetti-0.2.3}/scripts/setup +0 -0
  13. {pybluetti-0.2.2 → pybluetti-0.2.3}/scripts/test +0 -0
  14. {pybluetti-0.2.2 → pybluetti-0.2.3}/scripts/typecheck +0 -0
  15. {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/__init__.py +0 -0
  16. {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/client.py +0 -0
  17. {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/const.py +0 -0
  18. {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/exceptions.py +0 -0
  19. {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/models.py +0 -0
  20. {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/product_client.py +0 -0
  21. {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/py.typed +0 -0
  22. {pybluetti-0.2.2 → pybluetti-0.2.3}/src/pybluetti/unify_response.py +0 -0
  23. {pybluetti-0.2.2 → pybluetti-0.2.3}/tests/__init__.py +0 -0
  24. {pybluetti-0.2.2 → pybluetti-0.2.3}/tests/test_const.py +0 -0
  25. {pybluetti-0.2.2 → pybluetti-0.2.3}/tests/test_exceptions.py +0 -0
  26. {pybluetti-0.2.2 → pybluetti-0.2.3}/tests/test_package.py +0 -0
  27. {pybluetti-0.2.2 → pybluetti-0.2.3}/tests/test_product_client.py +0 -0
  28. {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.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pybluetti"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Async Python client for the BLUETTI cloud API - device discovery, state, and control."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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 still retries with backoff
47
- regardless - some of these are transient - but nothing else
48
- surfaces a persistent one distinctly from a run-of-the-mill
49
- connection drop, so a caller that wants to react (log once, show
50
- the user something actionable) has no other hook for it.
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