pybluetti 0.2.2__tar.gz → 0.2.4__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.4}/CHANGELOG.md +8 -0
  2. {pybluetti-0.2.2 → pybluetti-0.2.4}/PKG-INFO +1 -1
  3. {pybluetti-0.2.2 → pybluetti-0.2.4}/pyproject.toml +1 -1
  4. {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/websocket.py +80 -7
  5. {pybluetti-0.2.2 → pybluetti-0.2.4}/tests/test_websocket.py +93 -0
  6. {pybluetti-0.2.2 → pybluetti-0.2.4}/.github/workflows/publish.yml +0 -0
  7. {pybluetti-0.2.2 → pybluetti-0.2.4}/.github/workflows/tests.yml +0 -0
  8. {pybluetti-0.2.2 → pybluetti-0.2.4}/.gitignore +0 -0
  9. {pybluetti-0.2.2 → pybluetti-0.2.4}/LICENSE +0 -0
  10. {pybluetti-0.2.2 → pybluetti-0.2.4}/README.md +0 -0
  11. {pybluetti-0.2.2 → pybluetti-0.2.4}/scripts/lint +0 -0
  12. {pybluetti-0.2.2 → pybluetti-0.2.4}/scripts/setup +0 -0
  13. {pybluetti-0.2.2 → pybluetti-0.2.4}/scripts/test +0 -0
  14. {pybluetti-0.2.2 → pybluetti-0.2.4}/scripts/typecheck +0 -0
  15. {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/__init__.py +0 -0
  16. {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/client.py +0 -0
  17. {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/const.py +0 -0
  18. {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/exceptions.py +0 -0
  19. {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/models.py +0 -0
  20. {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/product_client.py +0 -0
  21. {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/py.typed +0 -0
  22. {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/unify_response.py +0 -0
  23. {pybluetti-0.2.2 → pybluetti-0.2.4}/tests/__init__.py +0 -0
  24. {pybluetti-0.2.2 → pybluetti-0.2.4}/tests/test_const.py +0 -0
  25. {pybluetti-0.2.2 → pybluetti-0.2.4}/tests/test_exceptions.py +0 -0
  26. {pybluetti-0.2.2 → pybluetti-0.2.4}/tests/test_package.py +0 -0
  27. {pybluetti-0.2.2 → pybluetti-0.2.4}/tests/test_product_client.py +0 -0
  28. {pybluetti-0.2.2 → pybluetti-0.2.4}/tests/test_unify_response.py +0 -0
@@ -1,3 +1,11 @@
1
+ # 0.2.4
2
+
3
+ - `StompClient` accepts optional `app_key`/`app_ver` keyword arguments, sent as `x-app-key`/`x-app-ver` CONNECT headers (alongside a fixed `x-os:open`) - BLUETTI's cloud gateway does client identification/version gating on this connection (see 0.2.3's own `msgCode 600` fix), and BLUETTI's own official Home Assistant integration's client already sends exactly these three headers on every connection. Optional and independent of everything else here - a caller with nothing to identify itself with still gets exactly the previous behavior, not headers claiming an identity it doesn't have. Raises `ValueError` if only one of the two is given.
4
+
5
+ # 0.2.3
6
+
7
+ - `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.
8
+
1
9
  # 0.2.2
2
10
 
3
11
  - `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.4
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.4"
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,11 +19,27 @@ 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."""
25
41
 
26
- def __init__( # noqa: PLR0913 -- three optional, independently-set callbacks, all keyword-only; bundling them into one object would just move the same information one level down without simplifying a caller that only wants one of them
42
+ def __init__( # noqa: PLR0913 -- five optional, independently-set values, all keyword-only; bundling them into one object would just move the same information one level down without simplifying a caller that only wants some of them
27
43
  self,
28
44
  session: aiohttp.ClientSession,
29
45
  url: str,
@@ -32,6 +48,8 @@ class StompClient:
32
48
  handler: Callable[[str], None] | None = None,
33
49
  on_auth_expired: Callable[[], None] | None = None,
34
50
  on_error: Callable[[ApplicationRuntimeException], None] | None = None,
51
+ app_key: str | None = None,
52
+ app_ver: str | None = None,
35
53
  ) -> None:
36
54
  """
37
55
  Initialize the client.
@@ -43,18 +61,36 @@ class StompClient:
43
61
  - on_auth_expired: called when the cloud reports the access token as
44
62
  expired (msgCode 805), so the caller can react.
45
63
  - 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.
64
+ (a msgCode other than 805). The client keeps retrying with
65
+ backoff for most of these - some are transient - except a known
66
+ set (see _TERMINAL_ERROR_CODES) it stops retrying for, since
67
+ those are confirmed (or, per BLUETTI's own official client,
68
+ strongly implied) to never succeed on retry either. Either way,
69
+ nothing else surfaces a persistent one distinctly from a
70
+ run-of-the-mill connection drop, so a caller that wants to react
71
+ (log once, show the user something actionable) has no other hook
72
+ for it.
73
+ - app_key, app_ver: client-identification headers the CONNECT frame
74
+ sends as x-app-key/x-app-ver, alongside a fixed x-os:open (see
75
+ _client_identification_headers' own docstring for why this
76
+ exists at all). Optional and independent of everything else here
77
+ - a caller with nothing to identify itself with still gets a
78
+ connection attempt with exactly today's headers, not a forced
79
+ value it doesn't have. Raises ValueError if only one is given -
80
+ the cloud is only known to accept both together or neither.
51
81
  """
82
+ if (app_key is None) != (app_ver is None):
83
+ msg = "app_key and app_ver must be given together"
84
+ raise ValueError(msg)
85
+
52
86
  self._session = session
53
87
  self.__url = url + "/websocket"
54
88
  self.__headers = {
55
89
  "Host": self.__get_host(url),
56
90
  "Authorization": access_token,
57
91
  }
92
+ self._app_key = app_key
93
+ self._app_ver = app_ver
58
94
  self.__handler = handler
59
95
  self.on_auth_expired = on_auth_expired
60
96
  self.on_error = on_error
@@ -95,6 +131,30 @@ class StompClient:
95
131
  host = host.split(":")[0]
96
132
  return host
97
133
 
134
+ def _client_identification_headers(self) -> str:
135
+ """
136
+ Return the x-os/x-app-key/x-app-ver CONNECT header lines, or "".
137
+
138
+ The cloud's websocket gateway does client identification/version
139
+ gating - confirmed by a real, persistent rejection (msgCode 600,
140
+ "Upgrade required, and then reconfigure the BLUETTI integration")
141
+ that never once succeeded on retry against a real device (bluetti-
142
+ community/bluetti-home-assistant#35). BLUETTI's own official
143
+ client (bluetti-official/bluetti-home-assistant's api/websocket.py)
144
+ sends exactly these three headers on every CONNECT frame, which
145
+ this client never did.
146
+
147
+ x-os is always "open" - not something a caller configures, since
148
+ BLUETTI's own official client hardcodes the identical value
149
+ regardless of the platform the integration itself runs on.
150
+ x-app-key/x-app-ver are per-caller (see __init__'s own docstring):
151
+ a real client-identification value belongs to whichever
152
+ integration was actually issued one, not to this library itself.
153
+ """
154
+ if self._app_key is None or self._app_ver is None:
155
+ return ""
156
+ return f"x-os:open\nx-app-key:{self._app_key}\nx-app-ver:{self._app_ver}\n"
157
+
98
158
  async def connect(self) -> None:
99
159
  """Connect to the ws server and start the background receive/heartbeat tasks."""
100
160
  __LOGGER__.info("Start to connect the BLUETTI WebSocket Server.")
@@ -119,7 +179,8 @@ class StompClient:
119
179
  "Host:" + self.__headers["Host"] + "\n"
120
180
  "Authorization: " + self.__headers["Authorization"] + "\n"
121
181
  "heart-beat:10000,10000\n"
122
- "\n\x00\n"
182
+ + self._client_identification_headers()
183
+ + "\n\x00\n"
123
184
  )
124
185
  await self._ws.send_str(connect_frame)
125
186
  except Exception:
@@ -259,6 +320,18 @@ class StompClient:
259
320
  self.on_auth_expired()
260
321
  __LOGGER__.info("token have expired stop ws connect")
261
322
  else:
323
+ if error["msgCode"] in _TERMINAL_ERROR_CODES:
324
+ # Same "stop, don't retry" outcome as 805 above, reached
325
+ # the same way _run() already stops retrying on any other
326
+ # exit from its receive loop: setting running False here,
327
+ # before raising, means the "if self.running: reconnect()"
328
+ # check it does afterwards is already False by the time it
329
+ # runs. Deliberately not the 805 branch above - on_error
330
+ # (below, via the raise) still fires with the real message,
331
+ # instead of on_auth_expired's specifically-token-expired
332
+ # framing, which wouldn't be accurate here (see
333
+ # _TERMINAL_ERROR_CODES's own comment).
334
+ self.running = False
262
335
  raise ApplicationRuntimeException(msgCode=error["msgCode"], errMessage=error["message"])
263
336
 
264
337
  async def _handle_connected_frame(self, frame: stomper.Frame) -> None:
@@ -84,6 +84,20 @@ def test_get_host_strips_port_and_path(url, expected):
84
84
  assert StompClient._StompClient__get_host(url) == expected
85
85
 
86
86
 
87
+ # --- StompClient.__init__'s app_key/app_ver validation -----------------------
88
+
89
+ def test_init_raises_if_only_app_key_given():
90
+ session = _FakeSession(None)
91
+ with pytest.raises(ValueError, match="app_key and app_ver must be given together"):
92
+ StompClient(session, GATEWAY_WS_URL, "token", app_key="key")
93
+
94
+
95
+ def test_init_raises_if_only_app_ver_given():
96
+ session = _FakeSession(None)
97
+ with pytest.raises(ValueError, match="app_key and app_ver must be given together"):
98
+ StompClient(session, GATEWAY_WS_URL, "token", app_ver="1.3.0")
99
+
100
+
87
101
  # --- StompClient.connect ------------------------------------------------------
88
102
 
89
103
  async def test_connect_opens_socket_sends_connect_frame_and_starts_tasks():
@@ -100,6 +114,32 @@ async def test_connect_opens_socket_sends_connect_frame_and_starts_tasks():
100
114
  assert client._heartbeat_task is not None
101
115
 
102
116
 
117
+ async def test_connect_omits_client_identification_headers_by_default():
118
+ ws = _FakeWebSocket()
119
+ client, _session, _on_auth_expired = _client(ws)
120
+
121
+ await client.connect()
122
+
123
+ assert "x-app-key" not in ws.sent[0]
124
+ assert "x-app-ver" not in ws.sent[0]
125
+ assert "x-os" not in ws.sent[0]
126
+
127
+
128
+ async def test_connect_sends_client_identification_headers_when_given():
129
+ ws = _FakeWebSocket()
130
+ session = _FakeSession(ws)
131
+ client = StompClient(
132
+ session, GATEWAY_WS_URL, "token", app_key="the-key", app_ver="1.3.0"
133
+ )
134
+ client._ws = ws
135
+
136
+ await client.connect()
137
+
138
+ assert "x-os:open" in ws.sent[0]
139
+ assert "x-app-key:the-key" in ws.sent[0]
140
+ assert "x-app-ver:1.3.0" in ws.sent[0]
141
+
142
+
103
143
  async def test_connect_cancels_a_stale_heartbeat_task_from_a_previous_connection():
104
144
  ws = _FakeWebSocket()
105
145
  client, _session, _on_auth_expired = _client(ws)
@@ -351,6 +391,24 @@ async def test_run_catches_application_runtime_exception_logs_full_and_calls_on_
351
391
  client.reconnect.assert_awaited_once()
352
392
 
353
393
 
394
+ async def test_run_does_not_reconnect_after_a_terminal_error_code():
395
+ ws = _FakeWebSocket([_error_message(600, "Upgrade required")])
396
+ on_error = MagicMock()
397
+ session = _FakeSession(ws)
398
+ client = StompClient(session, GATEWAY_WS_URL, "token", on_error=on_error)
399
+ client._ws = ws
400
+ client.running = True
401
+ client.reconnect = AsyncMock()
402
+
403
+ await client._run()
404
+
405
+ on_error.assert_called_once()
406
+ assert on_error.call_args[0][0].msgCode == 600
407
+ assert ws.close_called is True
408
+ client.reconnect.assert_not_awaited()
409
+ assert client.running is False
410
+
411
+
354
412
  async def test_run_downgrades_repeated_identical_application_runtime_exception():
355
413
  ws = _FakeWebSocket([_error_message(500, "server error")])
356
414
  session = _FakeSession(ws)
@@ -442,6 +500,41 @@ async def test_handle_frame_error_other_code_raises():
442
500
  assert exc_info.value.msgCode == 500
443
501
 
444
502
 
503
+ async def test_handle_frame_error_other_code_does_not_stop_retrying():
504
+ # A non-terminal, non-805 code (500) must not be mistaken for one of
505
+ # the codes known to never succeed on retry - client.running is left
506
+ # exactly as it was (True: a real, live connection still exists).
507
+ client, _session, _on_auth_expired = _client()
508
+ client.running = True
509
+ payload = json.dumps({"msgCode": 500, "message": "server error"}).replace(":", "\\c")
510
+ raw = f"ERROR\nmessage:{payload}\n\n\x00"
511
+
512
+ with pytest.raises(ApplicationRuntimeException):
513
+ await client._handle_frame(raw)
514
+
515
+ assert client.running is True
516
+
517
+
518
+ @pytest.mark.parametrize("msg_code", [400, 403, 600])
519
+ async def test_handle_frame_error_terminal_code_stops_retrying_but_still_raises(msg_code):
520
+ # 400/403/600: confirmed (600) or strongly implied (400, 403 - see
521
+ # _TERMINAL_ERROR_CODES's own comment) to never succeed on retry.
522
+ # Unlike 805, these still raise (on_error fires with the real message)
523
+ # rather than calling on_auth_expired - none of the three necessarily
524
+ # mean the token itself is the problem.
525
+ client, _session, on_auth_expired = _client()
526
+ client.running = True
527
+ payload = json.dumps({"msgCode": msg_code, "message": "terminal"}).replace(":", "\\c")
528
+ raw = f"ERROR\nmessage:{payload}\n\n\x00"
529
+
530
+ with pytest.raises(ApplicationRuntimeException) as exc_info:
531
+ await client._handle_frame(raw)
532
+
533
+ assert exc_info.value.msgCode == msg_code
534
+ assert client.running is False
535
+ on_auth_expired.assert_not_called()
536
+
537
+
445
538
  async def test_handle_frame_connected_without_websocket_logs_and_returns():
446
539
  client, _session, _on_auth_expired = _client()
447
540
  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