pybluetti 0.2.4__tar.gz → 0.2.6__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.4 → pybluetti-0.2.6}/CHANGELOG.md +11 -0
  2. {pybluetti-0.2.4 → pybluetti-0.2.6}/PKG-INFO +1 -1
  3. {pybluetti-0.2.4 → pybluetti-0.2.6}/pyproject.toml +1 -1
  4. {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/__init__.py +2 -1
  5. {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/client.py +4 -2
  6. {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/exceptions.py +30 -0
  7. {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/websocket.py +70 -10
  8. pybluetti-0.2.6/tests/test_exceptions.py +58 -0
  9. {pybluetti-0.2.4 → pybluetti-0.2.6}/tests/test_product_client.py +18 -1
  10. {pybluetti-0.2.4 → pybluetti-0.2.6}/tests/test_websocket.py +123 -5
  11. pybluetti-0.2.4/tests/test_exceptions.py +0 -28
  12. {pybluetti-0.2.4 → pybluetti-0.2.6}/.github/workflows/publish.yml +0 -0
  13. {pybluetti-0.2.4 → pybluetti-0.2.6}/.github/workflows/tests.yml +0 -0
  14. {pybluetti-0.2.4 → pybluetti-0.2.6}/.gitignore +0 -0
  15. {pybluetti-0.2.4 → pybluetti-0.2.6}/LICENSE +0 -0
  16. {pybluetti-0.2.4 → pybluetti-0.2.6}/README.md +0 -0
  17. {pybluetti-0.2.4 → pybluetti-0.2.6}/scripts/lint +0 -0
  18. {pybluetti-0.2.4 → pybluetti-0.2.6}/scripts/setup +0 -0
  19. {pybluetti-0.2.4 → pybluetti-0.2.6}/scripts/test +0 -0
  20. {pybluetti-0.2.4 → pybluetti-0.2.6}/scripts/typecheck +0 -0
  21. {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/const.py +0 -0
  22. {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/models.py +0 -0
  23. {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/product_client.py +0 -0
  24. {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/py.typed +0 -0
  25. {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/unify_response.py +0 -0
  26. {pybluetti-0.2.4 → pybluetti-0.2.6}/tests/__init__.py +0 -0
  27. {pybluetti-0.2.4 → pybluetti-0.2.6}/tests/test_const.py +0 -0
  28. {pybluetti-0.2.4 → pybluetti-0.2.6}/tests/test_package.py +0 -0
  29. {pybluetti-0.2.4 → pybluetti-0.2.6}/tests/test_unify_response.py +0 -0
@@ -1,3 +1,14 @@
1
+ # 0.2.6
2
+
3
+ - Fixed `StompClient.connect()` never returning while the cloud is unreachable: a failed attempt awaited `reconnect()`, which slept and called `connect()` again, so every retry nested inside the caller's own await instead of running behind it. A caller that connects during its start-up - `bluetti-home-assistant` awaits this inside `async_setup_entry` - then stayed in start-up for the whole outage: the config entry sat on "initialising" for three hours, with no reload offered in its menu and nothing but a Home Assistant restart to clear it (bluetti-community/bluetti-home-assistant#65). `connect()` now makes a single attempt and schedules the retry loop in the background, and `reconnect()` loops instead of recursing, so the stack no longer grows by a frame per attempt either.
4
+ - Fixed a socket leak on the failing side of that loop: `ws_connect()` can succeed and the CONNECT frame still fail, which left that socket open with nothing left to close it - one per failed attempt, until the session's connection pool had no free slot and every new connection waited for one and timed out instead. That is the `_wait_for_available_connection` timeout in the same report, and the reason each retry took five minutes rather than the thirty seconds its backoff asked for. Every attempt now closes whatever the previous one left behind, on both paths.
5
+ - `reconnect_delay` is reset once a connection succeeds, so the backoff is per outage rather than cumulative: without it, every later reconnect started at whatever delay the last outage had grown it to, up to `max_reconnect_delay`.
6
+ - `disconnect()` now cancels a pending reconnect task as well as the receive and heartbeat ones.
7
+
8
+ # 0.2.5
9
+
10
+ - Added `HttpStatusException` (an `ApplicationRuntimeException`), raised by `_request()` when the gateway answers with a non-2xx HTTP status instead of the plain base class: `status` and `reason` carry the HTTP status and reason phrase (`str(exc)` is e.g. `[504] HTTP 504 Gateway Timeout` instead of `[504] An unknown error has occurred.`), `data` the response body, and `is_transient` says whether one immediate retry is worth a try (502/503/504). `msgCode` is still the HTTP status, so every existing caller keeps working; a caller can now tell a gateway 504 from an API `msgCode` that happens to be 504. Prompted by bluetti-community/bluetti-home-assistant#53, a lone 504 on one poll of thirty failing the whole poll.
11
+
1
12
  # 0.2.4
2
13
 
3
14
  - `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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pybluetti
3
- Version: 0.2.4
3
+ Version: 0.2.6
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.4"
7
+ version = "0.2.6"
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"
@@ -12,7 +12,7 @@ bluetti-home-assistant to depend on this package is a separate follow-up.
12
12
 
13
13
  from .client import Bluetti
14
14
  from .const import Method
15
- from .exceptions import ApplicationRuntimeException
15
+ from .exceptions import ApplicationRuntimeException, HttpStatusException
16
16
  from .models import UserProduct
17
17
  from .product_client import ProductClient
18
18
  from .unify_response import UnifyResponse
@@ -23,6 +23,7 @@ __version__ = "0.1.1"
23
23
  __all__ = [
24
24
  "ApplicationRuntimeException",
25
25
  "Bluetti",
26
+ "HttpStatusException",
26
27
  "Method",
27
28
  "ProductClient",
28
29
  "StompClient",
@@ -10,7 +10,7 @@ import aiohttp
10
10
  from pydantic import TypeAdapter
11
11
 
12
12
  from .const import Method
13
- from .exceptions import ApplicationRuntimeException
13
+ from .exceptions import HttpStatusException
14
14
  from .unify_response import UnifyResponse
15
15
 
16
16
 
@@ -107,7 +107,9 @@ class Bluetti:
107
107
  self.logger.debug("<====== Server response type is: %s", response.content_type)
108
108
 
109
109
  if not response.ok:
110
- raise ApplicationRuntimeException(msgCode=response.status, data=await response.text())
110
+ raise HttpStatusException(
111
+ response.status, response.reason or "", data=await response.text()
112
+ )
111
113
 
112
114
  if response.content_type.lower().startswith("application/json"):
113
115
  data = await response.json() # read response body to JSON
@@ -33,3 +33,33 @@ class ApplicationRuntimeException(Exception):
33
33
  # a person actually looks by default). .message stays the plain,
34
34
  # code-free text for any caller that only wants that.
35
35
  super().__init__(f"[{msgCode}] {self.message}")
36
+
37
+
38
+ class HttpStatusException(ApplicationRuntimeException):
39
+ """
40
+ Raised when the BLUETTI gateway answers with a non-2xx HTTP status.
41
+
42
+ The API's own failures come back as HTTP 200 with a non-zero ``msgCode``
43
+ in the body; this one is the layer below that - the gateway itself
44
+ (502/503/504 from a proxy, 401 from the edge) - and ``msgCode`` is the
45
+ HTTP status code, which is what every caller matched on before this
46
+ class existed (still an ApplicationRuntimeException, so nothing
47
+ changes for them). ``status`` says the same thing under its real name,
48
+ ``reason`` is the HTTP reason phrase, ``data`` the response body. A
49
+ caller that wants to retry a transient gateway error - a lone 504 on
50
+ one poll of thirty (bluetti-community/bluetti-home-assistant#53) - can
51
+ now tell it apart from an API ``msgCode`` that happens to be 504.
52
+ """
53
+
54
+ status: int
55
+ reason: str
56
+
57
+ def __init__(self, status: int, reason: str, data: dict[str, Any] | str | None = None) -> None:
58
+ self.status = status
59
+ self.reason = reason
60
+ super().__init__(msgCode=status, data=data, errMessage=f"HTTP {status} {reason}".rstrip())
61
+
62
+ @property
63
+ def is_transient(self) -> bool:
64
+ """Whether one immediate retry is worth a try (a proxy/gateway hiccup)."""
65
+ return self.status in (502, 503, 504)
@@ -99,6 +99,7 @@ class StompClient:
99
99
 
100
100
  self._receive_task: asyncio.Task[None] | None = None
101
101
  self._heartbeat_task: asyncio.Task[None] | None = None
102
+ self._reconnect_task: asyncio.Task[None] | None = None
102
103
  self.heartbeat_interval = 10
103
104
  # The most recent ApplicationRuntimeException message _run() logged
104
105
  # at full severity, cleared once a CONNECTED frame proves the
@@ -156,10 +157,25 @@ class StompClient:
156
157
  return f"x-os:open\nx-app-key:{self._app_key}\nx-app-ver:{self._app_ver}\n"
157
158
 
158
159
  async def connect(self) -> None:
159
- """Connect to the ws server and start the background receive/heartbeat tasks."""
160
+ """
161
+ Connect to the ws server and start the background receive/heartbeat tasks.
162
+
163
+ Returns once a single attempt has been made: a failure schedules the
164
+ retry in the background instead of awaiting it here. Callers connect
165
+ during their own start-up - Home Assistant awaits this inside
166
+ async_setup_entry - and a retry loop that only returns on success
167
+ leaves that start-up hanging for as long as the cloud is unreachable,
168
+ with the entry stuck "initialising", no reload offered, and nothing
169
+ but a restart to clear it (bluetti-community/bluetti-home-assistant#65).
170
+ """
160
171
  __LOGGER__.info("Start to connect the BLUETTI WebSocket Server.")
161
172
  self.running = True
173
+ if await self._connect_once():
174
+ return
175
+ self._schedule_reconnect()
162
176
 
177
+ async def _connect_once(self) -> bool:
178
+ """One connection attempt; True when the socket and its tasks are up."""
163
179
  # A reconnect (whether from a dropped connection or a rejected one)
164
180
  # leaves the previous heartbeat task still scheduled on the old,
165
181
  # now-stale websocket - only the msgCode-805 path cancels it before
@@ -169,6 +185,7 @@ class StompClient:
169
185
  # whatever actually triggered this reconnect.
170
186
  if self._heartbeat_task is not None:
171
187
  self._heartbeat_task.cancel()
188
+ await self._close_socket()
172
189
 
173
190
  try:
174
191
  self._ws = await self._session.ws_connect(self.__url, headers=self.__headers)
@@ -187,18 +204,50 @@ class StompClient:
187
204
  # Same resilience as a run-time disconnect: log and retry with
188
205
  # backoff rather than letting a connection failure go silent.
189
206
  __LOGGER__.exception("Failed to connect to the BLUETTI WebSocket Server")
190
- await self.reconnect()
191
- return
207
+ # ws_connect can succeed and the CONNECT frame still fail, which
208
+ # leaves that socket open with nothing left to close it. One per
209
+ # failed attempt fills the session's connection pool, until every
210
+ # new connection waits for a free slot and times out - which is
211
+ # what a failing reconnect loop eventually does to the rest of the
212
+ # integration (bluetti-community/bluetti-home-assistant#65).
213
+ await self._close_socket()
214
+ return False
192
215
 
193
216
  __LOGGER__.info("Connect the BLUETTI WebSocket Server successfully.")
194
217
 
218
+ # The backoff is per outage, not cumulative: without this, every later
219
+ # reconnect starts at whatever delay the last outage grew it to.
220
+ self.reconnect_delay = 1
195
221
  self._receive_task = asyncio.ensure_future(self._run())
196
222
  self._heartbeat_task = asyncio.ensure_future(self._heartbeat_loop())
223
+ return True
224
+
225
+ async def _close_socket(self) -> None:
226
+ """Close and forget the current socket, if there is one left open."""
227
+ ws, self._ws = self._ws, None
228
+ if ws is None or ws.closed:
229
+ return
230
+ try:
231
+ await ws.close()
232
+ except Exception:
233
+ __LOGGER__.debug("Error while closing a stale websocket", exc_info=True)
234
+
235
+ def _schedule_reconnect(self) -> None:
236
+ """Run the retry loop in the background, unless one is already running."""
237
+ if not self.running:
238
+ return
239
+ if self._reconnect_task is not None and not self._reconnect_task.done():
240
+ return
241
+ self._reconnect_task = asyncio.ensure_future(self.reconnect())
197
242
 
198
243
  async def disconnect(self) -> None:
199
244
  """Stop reconnecting, cancel background tasks, and close the connection."""
200
245
  self.running = False
201
- tasks = [t for t in (self._receive_task, self._heartbeat_task) if t is not None]
246
+ tasks = [
247
+ t
248
+ for t in (self._receive_task, self._heartbeat_task, self._reconnect_task)
249
+ if t is not None
250
+ ]
202
251
  for task in tasks:
203
252
  task.cancel()
204
253
  if tasks:
@@ -207,14 +256,25 @@ class StompClient:
207
256
  await self._ws.close()
208
257
 
209
258
  async def reconnect(self) -> None:
210
- """Reconnect with exponential backoff, if still running."""
211
- __LOGGER__.info("Websocket reconnect")
212
- if self.running:
259
+ """
260
+ Retry with exponential backoff until connected, or until stopped.
261
+
262
+ A loop rather than a call back into connect(): each retry used to nest
263
+ inside the previous one's await, so the first caller's await never
264
+ returned while the cloud stayed unreachable, and the stack grew by a
265
+ frame per attempt (bluetti-community/bluetti-home-assistant#65).
266
+ """
267
+ if not self.running:
268
+ __LOGGER__.info("Websocket have stop do not reconnect")
269
+ return
270
+ while self.running:
271
+ __LOGGER__.info("Websocket reconnect")
213
272
  await asyncio.sleep(self.reconnect_delay)
214
273
  self.reconnect_delay = min(self.reconnect_delay * 2, self.max_reconnect_delay)
215
- await self.connect()
216
- else:
217
- __LOGGER__.info("Websocket have stop do not reconnect")
274
+ if not self.running:
275
+ return
276
+ if await self._connect_once():
277
+ return
218
278
 
219
279
  async def _heartbeat_loop(self) -> None:
220
280
  r"""Send a STOMP heartbeat ("\n") on the configured interval."""
@@ -0,0 +1,58 @@
1
+ """Tests for exceptions.py."""
2
+
3
+ from pybluetti.exceptions import ApplicationRuntimeException, HttpStatusException
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"
29
+
30
+
31
+ def test_http_status_exception_is_an_application_runtime_exception() -> None:
32
+ # Every caller that matched on ApplicationRuntimeException / msgCode
33
+ # before this class existed keeps working unchanged.
34
+ err = HttpStatusException(504, "Gateway Timeout", data="<html>...</html>")
35
+
36
+ assert isinstance(err, ApplicationRuntimeException)
37
+ assert err.msgCode == 504
38
+ assert err.status == 504
39
+ assert err.reason == "Gateway Timeout"
40
+ assert err.data == "<html>...</html>"
41
+ assert str(err) == "[504] HTTP 504 Gateway Timeout"
42
+
43
+
44
+ def test_http_status_exception_without_a_reason_phrase() -> None:
45
+ err = HttpStatusException(502, "")
46
+
47
+ assert str(err) == "[502] HTTP 502"
48
+
49
+
50
+ def test_transient_gateway_statuses() -> None:
51
+ # A lone 502/503/504 on one poll of thirty is a proxy hiccup worth one
52
+ # immediate retry (bluetti-community/bluetti-home-assistant#53); a
53
+ # 401 or 404 is not.
54
+ assert HttpStatusException(502, "Bad Gateway").is_transient
55
+ assert HttpStatusException(503, "Service Unavailable").is_transient
56
+ assert HttpStatusException(504, "Gateway Timeout").is_transient
57
+ assert not HttpStatusException(401, "Unauthorized").is_transient
58
+ assert not HttpStatusException(404, "Not Found").is_transient
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock
4
4
 
5
5
  import pytest
6
6
 
7
- from pybluetti.exceptions import ApplicationRuntimeException
7
+ from pybluetti.exceptions import ApplicationRuntimeException, HttpStatusException
8
8
  from pybluetti.product_client import ProductClient
9
9
 
10
10
  GATEWAY_URL = "https://gw.bluettipower.com"
@@ -13,6 +13,7 @@ GATEWAY_URL = "https://gw.bluettipower.com"
13
13
  class _FakeResponse:
14
14
  def __init__(self, status=200, content_type="application/json", json_data=None, text_data=""):
15
15
  self.status = status
16
+ self.reason = {200: "OK", 401: "Unauthorized", 504: "Gateway Timeout"}.get(status)
16
17
  self.ok = 200 <= status < 400
17
18
  self.content_type = content_type
18
19
  self.url = "https://gw.bluettipower.com/fake"
@@ -132,6 +133,22 @@ async def test_request_raises_on_non_ok_status():
132
133
  assert exc_info.value.data == "unauthorized"
133
134
 
134
135
 
136
+ async def test_request_raises_http_status_exception_with_status_and_reason():
137
+ # A gateway timeout is the HTTP layer, not the API: the exception says
138
+ # so, and says it is transient (bluetti-community/bluetti-home-assistant#53).
139
+ response = _FakeResponse(status=504, text_data="An unknown error has occurred.")
140
+ session = _FakeSession(response)
141
+ client, _on_auth_expired = _client(session)
142
+
143
+ with pytest.raises(HttpStatusException) as exc_info:
144
+ await client.get_user_products()
145
+
146
+ assert exc_info.value.status == 504
147
+ assert exc_info.value.reason == "Gateway Timeout"
148
+ assert exc_info.value.is_transient
149
+ assert str(exc_info.value) == "[504] HTTP 504 Gateway Timeout"
150
+
151
+
135
152
  async def test_request_returns_raw_text_for_non_json_response():
136
153
  response = _FakeResponse(content_type="text/plain", text_data="plain response")
137
154
  session = _FakeSession(response)
@@ -168,7 +168,11 @@ async def test_update_access_token_affects_the_next_connect():
168
168
  await client.disconnect() # tidy up the background tasks started above
169
169
 
170
170
 
171
- async def test_connect_failure_triggers_reconnect():
171
+ async def test_connect_failure_schedules_a_reconnect_and_returns():
172
+ # Regression test: connect() used to await the retry loop, so a caller
173
+ # connecting during its own start-up - Home Assistant inside
174
+ # async_setup_entry - hung there for as long as the cloud was unreachable
175
+ # (bluetti-community/bluetti-home-assistant#65). It must return.
172
176
  class _FailingSession:
173
177
  async def ws_connect(self, url, **kwargs):
174
178
  msg = "boom"
@@ -179,9 +183,82 @@ async def test_connect_failure_triggers_reconnect():
179
183
 
180
184
  await client.connect()
181
185
 
186
+ assert client._reconnect_task is not None
187
+ await client._reconnect_task
182
188
  client.reconnect.assert_awaited_once()
183
189
 
184
190
 
191
+ async def test_connect_closes_a_socket_whose_connect_frame_failed():
192
+ # The leak behind the timeouts in #65: ws_connect can succeed and the
193
+ # CONNECT frame still fail, leaving that socket open with nothing left to
194
+ # close it - one per failed attempt, until the pool has no free slot.
195
+ ws = _FakeWebSocket()
196
+
197
+ async def _failing_send(_data):
198
+ msg = "boom"
199
+ raise aiohttp.ClientConnectionError(msg)
200
+
201
+ ws.send_str = _failing_send
202
+ client, _session, _on_auth_expired = _client(ws)
203
+ client.reconnect = AsyncMock()
204
+
205
+ await client.connect()
206
+
207
+ assert ws.close_called is True
208
+ assert client._ws is None
209
+
210
+
211
+ async def test_connect_resets_the_backoff_so_it_is_per_outage():
212
+ ws = _FakeWebSocket()
213
+ client, _session, _on_auth_expired = _client(ws)
214
+ client.reconnect_delay = 30
215
+
216
+ await client.connect()
217
+
218
+ assert client.reconnect_delay == 1
219
+
220
+ await client.disconnect() # tidy up the background tasks started above
221
+
222
+
223
+ async def test_schedule_reconnect_does_nothing_when_stopped_or_already_running():
224
+ client, _session, _on_auth_expired = _client()
225
+ client.running = False
226
+ client._schedule_reconnect()
227
+ assert client._reconnect_task is None
228
+
229
+ client.running = True
230
+ running_task = MagicMock()
231
+ running_task.done.return_value = False
232
+ client._reconnect_task = running_task
233
+ client._schedule_reconnect()
234
+ assert client._reconnect_task is running_task
235
+
236
+
237
+ async def test_close_socket_survives_a_socket_that_will_not_close():
238
+ ws = _FakeWebSocket()
239
+
240
+ async def _failing_close():
241
+ msg = "boom"
242
+ raise aiohttp.ClientConnectionError(msg)
243
+
244
+ ws.close = _failing_close
245
+ client, _session, _on_auth_expired = _client(ws)
246
+
247
+ await client._close_socket() # must not raise
248
+
249
+ assert client._ws is None
250
+
251
+
252
+ async def test_close_socket_leaves_an_already_closed_socket_alone():
253
+ ws = _FakeWebSocket()
254
+ ws.closed = True
255
+ client, _session, _on_auth_expired = _client(ws)
256
+
257
+ await client._close_socket()
258
+
259
+ assert ws.close_called is False
260
+
261
+
185
262
  # --- StompClient.disconnect ---------------------------------------------------
186
263
 
187
264
  async def test_disconnect_cancels_tasks_and_closes_socket():
@@ -208,26 +285,67 @@ async def test_reconnect_when_running_backs_off_and_reconnects():
208
285
  client.running = True
209
286
  client.reconnect_delay = 1
210
287
  client.max_reconnect_delay = 30
211
- client.connect = AsyncMock()
288
+ client._connect_once = AsyncMock(return_value=True)
212
289
 
213
290
  with patch("pybluetti.websocket.asyncio.sleep", AsyncMock()) as mock_sleep:
214
291
  await client.reconnect()
215
292
 
216
293
  mock_sleep.assert_awaited_once_with(1)
217
294
  assert client.reconnect_delay == 2
218
- client.connect.assert_awaited_once()
295
+ client._connect_once.assert_awaited_once()
296
+
297
+
298
+ async def test_reconnect_keeps_trying_until_a_connection_is_made():
299
+ # A loop, not a recursive call back into connect(): each retry used to
300
+ # nest inside the previous one's await (#65).
301
+ client, _session, _on_auth_expired = _client()
302
+ client.running = True
303
+ client.reconnect_delay = 1
304
+ client.max_reconnect_delay = 4
305
+ client._connect_once = AsyncMock(side_effect=[False, False, True])
306
+
307
+ with patch("pybluetti.websocket.asyncio.sleep", AsyncMock()) as mock_sleep:
308
+ await client.reconnect()
309
+
310
+ assert client._connect_once.await_count == 3
311
+ assert [c.args[0] for c in mock_sleep.await_args_list] == [1, 2, 4]
312
+
313
+
314
+ async def test_reconnect_stops_when_the_client_is_disconnected_mid_wait():
315
+ client, _session, _on_auth_expired = _client()
316
+ client.running = True
317
+ client._connect_once = AsyncMock(return_value=False)
318
+
319
+ async def _stop(_delay):
320
+ client.running = False
321
+
322
+ with patch("pybluetti.websocket.asyncio.sleep", AsyncMock(side_effect=_stop)):
323
+ await client.reconnect()
324
+
325
+ client._connect_once.assert_not_awaited()
219
326
 
220
327
 
221
328
  async def test_reconnect_when_stopped_does_nothing():
222
329
  client, _session, _on_auth_expired = _client()
223
330
  client.running = False
224
- client.connect = AsyncMock()
331
+ client._connect_once = AsyncMock()
225
332
 
226
333
  with patch("pybluetti.websocket.asyncio.sleep", AsyncMock()) as mock_sleep:
227
334
  await client.reconnect()
228
335
 
229
336
  mock_sleep.assert_not_awaited()
230
- client.connect.assert_not_awaited()
337
+ client._connect_once.assert_not_awaited()
338
+
339
+
340
+ async def test_disconnect_cancels_a_pending_reconnect():
341
+ client, _session, _on_auth_expired = _client()
342
+ pending = MagicMock()
343
+ client._reconnect_task = pending
344
+
345
+ with patch("pybluetti.websocket.asyncio.gather", AsyncMock()):
346
+ await client.disconnect()
347
+
348
+ pending.cancel.assert_called_once()
231
349
 
232
350
 
233
351
  # --- StompClient._heartbeat_loop ----------------------------------------------
@@ -1,28 +0,0 @@
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"
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