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.
- {pybluetti-0.2.2 → pybluetti-0.2.4}/CHANGELOG.md +8 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/PKG-INFO +1 -1
- {pybluetti-0.2.2 → pybluetti-0.2.4}/pyproject.toml +1 -1
- {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/websocket.py +80 -7
- {pybluetti-0.2.2 → pybluetti-0.2.4}/tests/test_websocket.py +93 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/.github/workflows/publish.yml +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/.github/workflows/tests.yml +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/.gitignore +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/LICENSE +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/README.md +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/scripts/lint +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/scripts/setup +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/scripts/test +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/scripts/typecheck +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/__init__.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/client.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/const.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/exceptions.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/models.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/product_client.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/py.typed +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/src/pybluetti/unify_response.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/tests/__init__.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/tests/test_const.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/tests/test_exceptions.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/tests/test_package.py +0 -0
- {pybluetti-0.2.2 → pybluetti-0.2.4}/tests/test_product_client.py +0 -0
- {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.
|
|
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
|
|
@@ -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 --
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
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
|