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.
Files changed (29) hide show
  1. {pybluetti-0.2.0 → pybluetti-0.2.2}/CHANGELOG.md +8 -0
  2. {pybluetti-0.2.0 → pybluetti-0.2.2}/PKG-INFO +1 -1
  3. {pybluetti-0.2.0 → pybluetti-0.2.2}/pyproject.toml +1 -1
  4. pybluetti-0.2.2/src/pybluetti/exceptions.py +35 -0
  5. {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/websocket.py +14 -0
  6. pybluetti-0.2.2/tests/test_exceptions.py +28 -0
  7. {pybluetti-0.2.0 → pybluetti-0.2.2}/tests/test_websocket.py +21 -5
  8. pybluetti-0.2.0/src/pybluetti/exceptions.py +0 -22
  9. {pybluetti-0.2.0 → pybluetti-0.2.2}/.github/workflows/publish.yml +0 -0
  10. {pybluetti-0.2.0 → pybluetti-0.2.2}/.github/workflows/tests.yml +0 -0
  11. {pybluetti-0.2.0 → pybluetti-0.2.2}/.gitignore +0 -0
  12. {pybluetti-0.2.0 → pybluetti-0.2.2}/LICENSE +0 -0
  13. {pybluetti-0.2.0 → pybluetti-0.2.2}/README.md +0 -0
  14. {pybluetti-0.2.0 → pybluetti-0.2.2}/scripts/lint +0 -0
  15. {pybluetti-0.2.0 → pybluetti-0.2.2}/scripts/setup +0 -0
  16. {pybluetti-0.2.0 → pybluetti-0.2.2}/scripts/test +0 -0
  17. {pybluetti-0.2.0 → pybluetti-0.2.2}/scripts/typecheck +0 -0
  18. {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/__init__.py +0 -0
  19. {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/client.py +0 -0
  20. {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/const.py +0 -0
  21. {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/models.py +0 -0
  22. {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/product_client.py +0 -0
  23. {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/py.typed +0 -0
  24. {pybluetti-0.2.0 → pybluetti-0.2.2}/src/pybluetti/unify_response.py +0 -0
  25. {pybluetti-0.2.0 → pybluetti-0.2.2}/tests/__init__.py +0 -0
  26. {pybluetti-0.2.0 → pybluetti-0.2.2}/tests/test_const.py +0 -0
  27. {pybluetti-0.2.0 → pybluetti-0.2.2}/tests/test_package.py +0 -0
  28. {pybluetti-0.2.0 → pybluetti-0.2.2}/tests/test_product_client.py +0 -0
  29. {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.0
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pybluetti"
7
- version = "0.2.0"
7
+ version = "0.2.2"
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"
@@ -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
- client, _session, _on_auth_expired = _client(_BrokenWebSocket())
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