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.
- {pybluetti-0.2.4 → pybluetti-0.2.6}/CHANGELOG.md +11 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/PKG-INFO +1 -1
- {pybluetti-0.2.4 → pybluetti-0.2.6}/pyproject.toml +1 -1
- {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/__init__.py +2 -1
- {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/client.py +4 -2
- {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/exceptions.py +30 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/websocket.py +70 -10
- pybluetti-0.2.6/tests/test_exceptions.py +58 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/tests/test_product_client.py +18 -1
- {pybluetti-0.2.4 → pybluetti-0.2.6}/tests/test_websocket.py +123 -5
- pybluetti-0.2.4/tests/test_exceptions.py +0 -28
- {pybluetti-0.2.4 → pybluetti-0.2.6}/.github/workflows/publish.yml +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/.github/workflows/tests.yml +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/.gitignore +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/LICENSE +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/README.md +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/scripts/lint +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/scripts/setup +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/scripts/test +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/scripts/typecheck +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/const.py +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/models.py +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/product_client.py +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/py.typed +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/src/pybluetti/unify_response.py +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/tests/__init__.py +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/tests/test_const.py +0 -0
- {pybluetti-0.2.4 → pybluetti-0.2.6}/tests/test_package.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
191
|
-
|
|
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 = [
|
|
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
|
-
"""
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|