pybluetti 0.2.0__tar.gz → 0.2.2__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.0 → pybluetti-0.2.2}/CHANGELOG.md +8 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/PKG-INFO +1 -1
- {pybluetti-0.2.0 → pybluetti-0.2.2}/pyproject.toml +1 -1
- pybluetti-0.2.2/src/pybluetti/exceptions.py +35 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/websocket.py +14 -0
- pybluetti-0.2.2/tests/test_exceptions.py +28 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/tests/test_websocket.py +21 -5
- pybluetti-0.2.0/src/pybluetti/exceptions.py +0 -22
- {pybluetti-0.2.0 → pybluetti-0.2.2}/.github/workflows/publish.yml +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/.github/workflows/tests.yml +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/.gitignore +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/LICENSE +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/README.md +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/scripts/lint +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/scripts/setup +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/scripts/test +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/scripts/typecheck +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/__init__.py +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/client.py +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/const.py +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/models.py +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/product_client.py +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/py.typed +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/unify_response.py +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/tests/__init__.py +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/tests/test_const.py +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/tests/test_package.py +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/tests/test_product_client.py +0 -0
- {pybluetti-0.2.0 → pybluetti-0.2.2}/tests/test_unify_response.py +0 -0
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
# 0.2.2
|
|
2
|
+
|
|
3
|
+
- `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.
|
|
4
|
+
|
|
5
|
+
# 0.2.1rc1 (pre-release)
|
|
6
|
+
|
|
7
|
+
- Fixed `_run()` leaving the abandoned connection open on every path except a token-expiry (msgCode 805): an `ApplicationRuntimeException`, a plain ERROR/CLOSE message, or an unexpected crash all left `close()` uncalled on the old connection. 0.2.0's heartbeat-task cancellation in `connect()` was only a partial fix - the still-open connection let a concurrently-running heartbeat send slip past its own is-it-closed check and fail against the transport before aiohttp itself noticed the remote side had already closed, reported as "Failed to send heartbeat: Cannot write to closing transport" repeating on every reconnect cycle in production (real-world confirmation: bluetti-official/bluetti-home-assistant#145). `_run()` now closes the connection itself as soon as it decides to abandon it, closing that race instead of just narrowing it.
|
|
8
|
+
|
|
1
9
|
# 0.2.0
|
|
2
10
|
|
|
3
11
|
- **Breaking**: `StompClient.__init__`'s `handler`, `on_auth_expired`, and the new `on_error` are now keyword-only (`session`/`url`/`access_token` stay positional). Update any call passing `handler` positionally to `handler=...`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pybluetti
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
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
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Exceptions raised by the pybluetti client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ApplicationRuntimeException(Exception):
|
|
7
|
+
"""Raised when a BLUETTI cloud API call fails."""
|
|
8
|
+
|
|
9
|
+
message: str = "An unknown error has occurred."
|
|
10
|
+
msgCode: int
|
|
11
|
+
data: dict[str, Any] | str | None = None
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self, msgCode: int, data: dict[str, Any] | str | None = None, errMessage: str | None = None
|
|
15
|
+
) -> None:
|
|
16
|
+
self.msgCode = msgCode
|
|
17
|
+
self.data = data
|
|
18
|
+
|
|
19
|
+
if errMessage is not None:
|
|
20
|
+
self.message = errMessage
|
|
21
|
+
|
|
22
|
+
# msgCode is folded into str(self) (not just kept as the separate
|
|
23
|
+
# .msgCode attribute) because it's the one piece of information
|
|
24
|
+
# every existing caller that logs or displays this exception
|
|
25
|
+
# already shows without any changes on their end - notably
|
|
26
|
+
# bluetti-home-assistant's own websocket-error Repair issue, which
|
|
27
|
+
# surfaces str(err) directly to the end user (see that repo's
|
|
28
|
+
# __init__.py, ISSUE_ID_WEBSOCKET_ERROR). Without it, a real user
|
|
29
|
+
# hitting an unrecognized error code had no way to report which one
|
|
30
|
+
# they got short of enabling debug logging and finding the raw STOMP
|
|
31
|
+
# frame themselves (see bluetti-community/bluetti-home-assistant#35,
|
|
32
|
+
# where exactly that happened - the code was never surfaced anywhere
|
|
33
|
+
# a person actually looks by default). .message stays the plain,
|
|
34
|
+
# code-free text for any caller that only wants that.
|
|
35
|
+
super().__init__(f"[{msgCode}] {self.message}")
|
|
@@ -190,6 +190,20 @@ class StompClient:
|
|
|
190
190
|
except Exception:
|
|
191
191
|
__LOGGER__.exception("BLUETTI WebSocket task crashed")
|
|
192
192
|
|
|
193
|
+
# Only the msgCode-805 path (handled inline above, not via this
|
|
194
|
+
# except block) closes ws itself. Every other way out of the loop
|
|
195
|
+
# above - an ERROR/CLOSE message, a raised ApplicationRuntimeException,
|
|
196
|
+
# or an unexpected crash - leaves it open from our own side. The
|
|
197
|
+
# heartbeat task's own is-it-closed check races against however long
|
|
198
|
+
# aiohttp takes to notice the remote already closed its end, so it
|
|
199
|
+
# can still slip through and fail the write instead of cleanly
|
|
200
|
+
# breaking - closing it decisively here, as soon as we've decided to
|
|
201
|
+
# abandon this connection, is what actually closes that window
|
|
202
|
+
# (connect()'s heartbeat-task cancellation below is the remaining,
|
|
203
|
+
# much narrower backstop: mid-send at this exact instant).
|
|
204
|
+
if not ws.closed:
|
|
205
|
+
await ws.close()
|
|
206
|
+
|
|
193
207
|
if self.running:
|
|
194
208
|
await self.reconnect()
|
|
195
209
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Tests for exceptions.py."""
|
|
2
|
+
|
|
3
|
+
from pybluetti.exceptions import ApplicationRuntimeException
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_str_includes_msg_code_and_custom_message() -> None:
|
|
7
|
+
# The msgCode must be visible in str(exc) itself, not just the .msgCode
|
|
8
|
+
# attribute - see this file's own docstring in exceptions.py for why
|
|
9
|
+
# (bluetti-community/bluetti-home-assistant#35: a real user had no way
|
|
10
|
+
# to report which error code they got, since nothing that logs or
|
|
11
|
+
# displays this exception showed it).
|
|
12
|
+
err = ApplicationRuntimeException(msgCode=1234, errMessage="Upgrade required.")
|
|
13
|
+
|
|
14
|
+
assert str(err) == "[1234] Upgrade required."
|
|
15
|
+
assert err.message == "Upgrade required."
|
|
16
|
+
assert err.msgCode == 1234
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_str_includes_msg_code_with_default_message() -> None:
|
|
20
|
+
err = ApplicationRuntimeException(msgCode=500)
|
|
21
|
+
|
|
22
|
+
assert str(err) == "[500] An unknown error has occurred."
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_data_is_stored_unchanged() -> None:
|
|
26
|
+
err = ApplicationRuntimeException(msgCode=401, data="unauthorized")
|
|
27
|
+
|
|
28
|
+
assert err.data == "unauthorized"
|
|
@@ -273,6 +273,7 @@ async def test_run_breaks_on_error_message_type_and_reconnects_if_running():
|
|
|
273
273
|
|
|
274
274
|
await client._run()
|
|
275
275
|
|
|
276
|
+
assert ws.close_called is True
|
|
276
277
|
client.reconnect.assert_awaited_once()
|
|
277
278
|
|
|
278
279
|
|
|
@@ -289,6 +290,9 @@ async def test_run_breaks_on_close_message_type_and_skips_reconnect_when_stopped
|
|
|
289
290
|
|
|
290
291
|
async def test_run_catches_unexpected_exception_and_reconnects():
|
|
291
292
|
class _BrokenWebSocket:
|
|
293
|
+
def __init__(self):
|
|
294
|
+
self.closed = False
|
|
295
|
+
|
|
292
296
|
def __aiter__(self):
|
|
293
297
|
return self
|
|
294
298
|
|
|
@@ -296,12 +300,18 @@ async def test_run_catches_unexpected_exception_and_reconnects():
|
|
|
296
300
|
msg = "boom"
|
|
297
301
|
raise RuntimeError(msg)
|
|
298
302
|
|
|
299
|
-
|
|
303
|
+
async def close(self):
|
|
304
|
+
self.closed = True
|
|
305
|
+
|
|
306
|
+
ws = _BrokenWebSocket()
|
|
307
|
+
client, _session, _on_auth_expired = _client(ws)
|
|
300
308
|
client.running = True
|
|
301
309
|
client.reconnect = AsyncMock()
|
|
302
310
|
|
|
303
311
|
await client._run()
|
|
304
312
|
|
|
313
|
+
assert ws.closed is True
|
|
314
|
+
|
|
305
315
|
client.reconnect.assert_awaited_once()
|
|
306
316
|
|
|
307
317
|
|
|
@@ -331,7 +341,13 @@ async def test_run_catches_application_runtime_exception_logs_full_and_calls_on_
|
|
|
331
341
|
assert "BLUETTI WebSocket task crashed (repeat): %s" not in debug_messages
|
|
332
342
|
on_error.assert_called_once()
|
|
333
343
|
assert on_error.call_args[0][0].msgCode == 500
|
|
334
|
-
assert client._last_error_message == "server error"
|
|
344
|
+
assert client._last_error_message == "[500] server error"
|
|
345
|
+
# The real bug this guards against: _run() used to leave the abandoned
|
|
346
|
+
# connection open on this exact path (only msgCode 805 closed it), so a
|
|
347
|
+
# concurrently-running heartbeat task could still be mid-send against it
|
|
348
|
+
# by the time it actually finished closing - "Cannot write to closing
|
|
349
|
+
# transport", repeating on every retry cycle in production.
|
|
350
|
+
assert ws.close_called is True
|
|
335
351
|
client.reconnect.assert_awaited_once()
|
|
336
352
|
|
|
337
353
|
|
|
@@ -341,7 +357,7 @@ async def test_run_downgrades_repeated_identical_application_runtime_exception()
|
|
|
341
357
|
client = StompClient(session, GATEWAY_WS_URL, "token")
|
|
342
358
|
client._ws = ws
|
|
343
359
|
client.running = True
|
|
344
|
-
client._last_error_message = "server error" # already logged once, on a previous retry
|
|
360
|
+
client._last_error_message = "[500] server error" # already logged once, on a previous retry
|
|
345
361
|
client.reconnect = AsyncMock()
|
|
346
362
|
|
|
347
363
|
with patch("pybluetti.websocket.__LOGGER__") as logger:
|
|
@@ -358,14 +374,14 @@ async def test_run_relogs_in_full_once_the_error_message_changes():
|
|
|
358
374
|
client = StompClient(session, GATEWAY_WS_URL, "token")
|
|
359
375
|
client._ws = ws
|
|
360
376
|
client.running = True
|
|
361
|
-
client._last_error_message = "server error"
|
|
377
|
+
client._last_error_message = "[500] server error"
|
|
362
378
|
client.reconnect = AsyncMock()
|
|
363
379
|
|
|
364
380
|
with patch("pybluetti.websocket.__LOGGER__") as logger:
|
|
365
381
|
await client._run()
|
|
366
382
|
|
|
367
383
|
logger.exception.assert_called_once_with("BLUETTI WebSocket task crashed")
|
|
368
|
-
assert client._last_error_message == "a different error"
|
|
384
|
+
assert client._last_error_message == "[500] a different error"
|
|
369
385
|
|
|
370
386
|
|
|
371
387
|
async def test_run_application_runtime_exception_without_on_error_does_not_raise():
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"""Exceptions raised by the pybluetti client."""
|
|
2
|
-
|
|
3
|
-
from typing import Any
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class ApplicationRuntimeException(Exception):
|
|
7
|
-
"""Raised when a BLUETTI cloud API call fails."""
|
|
8
|
-
|
|
9
|
-
message: str = "An unknown error has occurred."
|
|
10
|
-
msgCode: int
|
|
11
|
-
data: dict[str, Any] | str | None = None
|
|
12
|
-
|
|
13
|
-
def __init__(
|
|
14
|
-
self, msgCode: int, data: dict[str, Any] | str | None = None, errMessage: str | None = None
|
|
15
|
-
) -> None:
|
|
16
|
-
self.msgCode = msgCode
|
|
17
|
-
self.data = data
|
|
18
|
-
|
|
19
|
-
if errMessage is not None:
|
|
20
|
-
self.message = errMessage
|
|
21
|
-
|
|
22
|
-
super().__init__(self.message)
|
|
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
|