beatbot-cloud 0.2.0__tar.gz → 0.4.0__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.
- {beatbot_cloud-0.2.0/src/beatbot_cloud.egg-info → beatbot_cloud-0.4.0}/PKG-INFO +22 -7
- beatbot_cloud-0.4.0/README.md +41 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/pyproject.toml +2 -1
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/src/beatbot_cloud/__init__.py +6 -0
- beatbot_cloud-0.4.0/src/beatbot_cloud/auth.py +23 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/src/beatbot_cloud/client.py +31 -36
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/src/beatbot_cloud/const.py +5 -0
- beatbot_cloud-0.4.0/src/beatbot_cloud/event_client.py +183 -0
- beatbot_cloud-0.4.0/src/beatbot_cloud/models.py +106 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/src/beatbot_cloud/protocol.py +12 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/src/beatbot_cloud/websocket.py +2 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0/src/beatbot_cloud.egg-info}/PKG-INFO +22 -7
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/src/beatbot_cloud.egg-info/SOURCES.txt +4 -0
- beatbot_cloud-0.4.0/tests/test_auth.py +28 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/tests/test_client.py +80 -31
- beatbot_cloud-0.4.0/tests/test_event_client.py +121 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/tests/test_protocol.py +9 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/tests/test_websocket.py +8 -0
- beatbot_cloud-0.2.0/README.md +0 -27
- beatbot_cloud-0.2.0/src/beatbot_cloud/models.py +0 -55
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/LICENSE +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/setup.cfg +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/src/beatbot_cloud/exceptions.py +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/src/beatbot_cloud/py.typed +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/src/beatbot_cloud.egg-info/dependency_links.txt +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/src/beatbot_cloud.egg-info/requires.txt +0 -0
- {beatbot_cloud-0.2.0 → beatbot_cloud-0.4.0}/src/beatbot_cloud.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: beatbot-cloud
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Asynchronous Python client for the Beatbot cloud API
|
|
5
5
|
Author: Beatbot Robotics
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
17
|
Classifier: Typing :: Typed
|
|
17
18
|
Requires-Python: >=3.11
|
|
18
19
|
Description-Content-Type: text/markdown
|
|
@@ -32,15 +33,30 @@ It provides region-aware REST access, typed device models, and a WebSocket event
|
|
|
32
33
|
transport without depending on Home Assistant.
|
|
33
34
|
|
|
34
35
|
```python
|
|
35
|
-
from beatbot_cloud import BeatbotClient
|
|
36
|
+
from beatbot_cloud import BeatbotClient, BeatbotEventClient
|
|
36
37
|
|
|
37
|
-
client = BeatbotClient(
|
|
38
|
+
client = BeatbotClient(
|
|
39
|
+
region="na",
|
|
40
|
+
session=session,
|
|
41
|
+
access_token=async_get_access_token,
|
|
42
|
+
)
|
|
38
43
|
devices = await client.get_devices()
|
|
44
|
+
|
|
45
|
+
events = BeatbotEventClient(
|
|
46
|
+
session,
|
|
47
|
+
client.event_stream_url,
|
|
48
|
+
client.async_get_access_token,
|
|
49
|
+
async_handle_event,
|
|
50
|
+
reconnect_callback=async_handle_reconnect,
|
|
51
|
+
token_refresh_callback=async_refresh_access_token,
|
|
52
|
+
)
|
|
53
|
+
await events.async_run()
|
|
39
54
|
```
|
|
40
55
|
|
|
41
|
-
The caller
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
The caller provides an access token or a synchronous/asynchronous token provider.
|
|
57
|
+
The library owns REST request construction and WebSocket reconnection; applications
|
|
58
|
+
can register callbacks for events, successful reconnections, and rejected-token
|
|
59
|
+
refreshes.
|
|
44
60
|
|
|
45
61
|
## Development
|
|
46
62
|
|
|
@@ -51,4 +67,3 @@ ruff check .
|
|
|
51
67
|
ruff format --check .
|
|
52
68
|
python -m build
|
|
53
69
|
```
|
|
54
|
-
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# beatbot-cloud
|
|
2
|
+
|
|
3
|
+
`beatbot-cloud` is the asynchronous Python client for Beatbot cloud accounts.
|
|
4
|
+
It provides region-aware REST access, typed device models, and a WebSocket event
|
|
5
|
+
transport without depending on Home Assistant.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from beatbot_cloud import BeatbotClient, BeatbotEventClient
|
|
9
|
+
|
|
10
|
+
client = BeatbotClient(
|
|
11
|
+
region="na",
|
|
12
|
+
session=session,
|
|
13
|
+
access_token=async_get_access_token,
|
|
14
|
+
)
|
|
15
|
+
devices = await client.get_devices()
|
|
16
|
+
|
|
17
|
+
events = BeatbotEventClient(
|
|
18
|
+
session,
|
|
19
|
+
client.event_stream_url,
|
|
20
|
+
client.async_get_access_token,
|
|
21
|
+
async_handle_event,
|
|
22
|
+
reconnect_callback=async_handle_reconnect,
|
|
23
|
+
token_refresh_callback=async_refresh_access_token,
|
|
24
|
+
)
|
|
25
|
+
await events.async_run()
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The caller provides an access token or a synchronous/asynchronous token provider.
|
|
29
|
+
The library owns REST request construction and WebSocket reconnection; applications
|
|
30
|
+
can register callbacks for events, successful reconnections, and rejected-token
|
|
31
|
+
refreshes.
|
|
32
|
+
|
|
33
|
+
## Development
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python -m pip install -e '.[test]'
|
|
37
|
+
pytest
|
|
38
|
+
ruff check .
|
|
39
|
+
ruff format --check .
|
|
40
|
+
python -m build
|
|
41
|
+
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "beatbot-cloud"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Asynchronous Python client for the Beatbot cloud API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -18,6 +18,7 @@ classifiers = [
|
|
|
18
18
|
"Programming Language :: Python :: 3.11",
|
|
19
19
|
"Programming Language :: Python :: 3.12",
|
|
20
20
|
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
21
22
|
"Typing :: Typed",
|
|
22
23
|
]
|
|
23
24
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Asynchronous client for the Beatbot cloud API."""
|
|
2
2
|
|
|
3
|
+
from .auth import decode_access_token
|
|
3
4
|
from .client import BeatbotClient
|
|
5
|
+
from .event_client import BeatbotEventClient
|
|
4
6
|
from .exceptions import (
|
|
5
7
|
BeatbotAuthenticationError,
|
|
6
8
|
BeatbotConnectionError,
|
|
@@ -15,6 +17,7 @@ from .protocol import (
|
|
|
15
17
|
DeviceError,
|
|
16
18
|
DeviceStatus,
|
|
17
19
|
ProductCategory,
|
|
20
|
+
error_for,
|
|
18
21
|
error_mask_for,
|
|
19
22
|
status_for,
|
|
20
23
|
)
|
|
@@ -28,6 +31,7 @@ __all__ = [
|
|
|
28
31
|
"BeatbotConnectionReplacedError",
|
|
29
32
|
"BeatbotDeviceData",
|
|
30
33
|
"BeatbotEvent",
|
|
34
|
+
"BeatbotEventClient",
|
|
31
35
|
"BeatbotEventError",
|
|
32
36
|
"BeatbotEventStream",
|
|
33
37
|
"BeatbotTokenRejectedError",
|
|
@@ -37,6 +41,8 @@ __all__ = [
|
|
|
37
41
|
"FirmwareVersion",
|
|
38
42
|
"ProductCategory",
|
|
39
43
|
"STATUS_BY_CATEGORY",
|
|
44
|
+
"error_for",
|
|
40
45
|
"error_mask_for",
|
|
41
46
|
"status_for",
|
|
47
|
+
"decode_access_token",
|
|
42
48
|
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Authentication helpers for Beatbot cloud tokens."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def decode_access_token(access_token: str) -> dict[str, Any] | None:
|
|
10
|
+
"""Decode the claims from a JWT access token without verifying its signature."""
|
|
11
|
+
try:
|
|
12
|
+
payload = access_token.split(".")[1]
|
|
13
|
+
payload += "=" * (-len(payload) % 4)
|
|
14
|
+
claims = json.loads(base64.urlsafe_b64decode(payload))
|
|
15
|
+
except (
|
|
16
|
+
binascii.Error,
|
|
17
|
+
IndexError,
|
|
18
|
+
json.JSONDecodeError,
|
|
19
|
+
UnicodeDecodeError,
|
|
20
|
+
ValueError,
|
|
21
|
+
):
|
|
22
|
+
return None
|
|
23
|
+
return claims if isinstance(claims, dict) else None
|
|
@@ -6,9 +6,10 @@ import json
|
|
|
6
6
|
import logging
|
|
7
7
|
from collections.abc import Awaitable, Callable
|
|
8
8
|
from http import HTTPStatus
|
|
9
|
-
from
|
|
9
|
+
from inspect import isawaitable
|
|
10
|
+
from typing import Any, TypeAlias
|
|
10
11
|
|
|
11
|
-
from aiohttp import
|
|
12
|
+
from aiohttp import ClientError, ClientSession, ClientTimeout
|
|
12
13
|
|
|
13
14
|
from .const import (
|
|
14
15
|
DEVICE_ACTIONS_PATH,
|
|
@@ -17,7 +18,6 @@ from .const import (
|
|
|
17
18
|
EVENTS_PATH,
|
|
18
19
|
HTTP_API_TIMEOUT,
|
|
19
20
|
INTERFACE_WORK_MODE,
|
|
20
|
-
OAUTH2_TOKEN_URL,
|
|
21
21
|
REGION_API_BASE_URL,
|
|
22
22
|
RESULT_SUCCESS_CODE,
|
|
23
23
|
)
|
|
@@ -27,39 +27,36 @@ from .models import BeatbotCapability, BeatbotDeviceData, FirmwareVersion
|
|
|
27
27
|
_LOGGER = logging.getLogger(__name__)
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
"""Subset of an aiohttp response used by the client."""
|
|
32
|
-
|
|
33
|
-
status: int
|
|
34
|
-
headers: dict[str, str]
|
|
35
|
-
|
|
36
|
-
async def text(self) -> str:
|
|
37
|
-
"""Return the response body."""
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
Requester: TypeAlias = Callable[..., Awaitable[Response]]
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _is_oauth_reauthentication_error(err: ClientResponseError) -> bool:
|
|
44
|
-
"""Return whether an OAuth token response requires user reauthentication."""
|
|
45
|
-
request_url = str(getattr(err.request_info, "real_url", "")).split("?", 1)[0]
|
|
46
|
-
return (
|
|
47
|
-
request_url == OAUTH2_TOKEN_URL
|
|
48
|
-
and HTTPStatus.BAD_REQUEST <= err.status < HTTPStatus.INTERNAL_SERVER_ERROR
|
|
49
|
-
and err.status not in (HTTPStatus.REQUEST_TIMEOUT, HTTPStatus.TOO_MANY_REQUESTS)
|
|
50
|
-
)
|
|
30
|
+
AccessTokenProvider: TypeAlias = Callable[[], str | Awaitable[str]]
|
|
51
31
|
|
|
52
32
|
|
|
53
33
|
class BeatbotClient:
|
|
54
|
-
"""Access the Beatbot cloud API
|
|
34
|
+
"""Access the Beatbot cloud API."""
|
|
55
35
|
|
|
56
|
-
def __init__(
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
region: str,
|
|
39
|
+
session: ClientSession,
|
|
40
|
+
access_token: str | AccessTokenProvider,
|
|
41
|
+
) -> None:
|
|
57
42
|
"""Initialize the client for an OAuth token's region claim."""
|
|
58
43
|
try:
|
|
59
44
|
self._base_url = REGION_API_BASE_URL[region]
|
|
60
45
|
except KeyError as err:
|
|
61
46
|
raise ValueError(f"Unknown or missing Beatbot region: {region!r}") from err
|
|
62
|
-
self.
|
|
47
|
+
self._session = session
|
|
48
|
+
self._access_token = access_token
|
|
49
|
+
|
|
50
|
+
async def async_get_access_token(self) -> str:
|
|
51
|
+
"""Return the current access token."""
|
|
52
|
+
token = (
|
|
53
|
+
self._access_token() if callable(self._access_token) else self._access_token
|
|
54
|
+
)
|
|
55
|
+
if isawaitable(token):
|
|
56
|
+
token = await token
|
|
57
|
+
if not token:
|
|
58
|
+
raise BeatbotAuthenticationError("Missing OAuth access token")
|
|
59
|
+
return token
|
|
63
60
|
|
|
64
61
|
@property
|
|
65
62
|
def event_stream_url(self) -> str:
|
|
@@ -79,22 +76,20 @@ class BeatbotClient:
|
|
|
79
76
|
json_body: Any | None = None,
|
|
80
77
|
) -> Any:
|
|
81
78
|
"""Request and validate a Beatbot result envelope."""
|
|
79
|
+
access_token = await self.async_get_access_token()
|
|
82
80
|
try:
|
|
83
|
-
response = await self.
|
|
81
|
+
response = await self._session.request(
|
|
84
82
|
method,
|
|
85
83
|
f"{self._base_url}{path}",
|
|
86
84
|
params=params,
|
|
87
85
|
json=json_body,
|
|
88
|
-
headers={
|
|
86
|
+
headers={
|
|
87
|
+
"Accept": "application/json",
|
|
88
|
+
"Authorization": f"Bearer {access_token}",
|
|
89
|
+
},
|
|
89
90
|
timeout=ClientTimeout(total=HTTP_API_TIMEOUT),
|
|
90
91
|
)
|
|
91
|
-
except
|
|
92
|
-
if _is_oauth_reauthentication_error(err):
|
|
93
|
-
raise BeatbotAuthenticationError(
|
|
94
|
-
"OAuth token refresh rejected; reauthentication required"
|
|
95
|
-
) from err
|
|
96
|
-
raise BeatbotConnectionError(str(err)) from err
|
|
97
|
-
except Exception as err:
|
|
92
|
+
except ClientError as err:
|
|
98
93
|
raise BeatbotConnectionError(str(err)) from err
|
|
99
94
|
|
|
100
95
|
body = await response.text()
|
|
@@ -21,3 +21,8 @@ EVENTS_PATH: Final = "/openapi/v1/ha/ws"
|
|
|
21
21
|
RESULT_SUCCESS_CODE: Final = 200
|
|
22
22
|
|
|
23
23
|
INTERFACE_WORK_MODE: Final = "select.work_mode"
|
|
24
|
+
INTERFACE_VACUUM_STATE: Final = "vacuum.state"
|
|
25
|
+
INTERFACE_VACUUM_BATTERY: Final = "vacuum.battery"
|
|
26
|
+
INTERFACE_SENSOR_ERROR: Final = "sensor.error"
|
|
27
|
+
INTERFACE_CHILD_LOCK: Final = "switch.child_lock"
|
|
28
|
+
INTERFACE_VOICE_DISTURB: Final = "switch.voice_disturb"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Resilient Beatbot cloud event client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import random
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from inspect import isawaitable
|
|
11
|
+
|
|
12
|
+
from aiohttp import ClientError, ClientSession
|
|
13
|
+
|
|
14
|
+
from .client import AccessTokenProvider
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
BeatbotAuthenticationError,
|
|
17
|
+
BeatbotConnectionError,
|
|
18
|
+
BeatbotConnectionReplacedError,
|
|
19
|
+
BeatbotEventError,
|
|
20
|
+
BeatbotTokenRejectedError,
|
|
21
|
+
)
|
|
22
|
+
from .models import BeatbotEvent
|
|
23
|
+
from .websocket import BeatbotEventStream
|
|
24
|
+
|
|
25
|
+
_LOGGER = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_EVENT_DEDUP_CACHE_SIZE = 256
|
|
28
|
+
_RECONNECT_DELAYS = (1.0, 2.0, 4.0, 8.0, 30.0, 60.0)
|
|
29
|
+
_RECONNECT_JITTER = 0.2
|
|
30
|
+
|
|
31
|
+
EventCallback = Callable[[BeatbotEvent], None | Awaitable[None]]
|
|
32
|
+
ReconnectCallback = Callable[[], None | Awaitable[None]]
|
|
33
|
+
TokenRefreshCallback = Callable[[str], str | Awaitable[str]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BeatbotEventClient:
|
|
37
|
+
"""Maintain and recover an account-scoped Beatbot event stream."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
session: ClientSession,
|
|
42
|
+
url: str,
|
|
43
|
+
access_token: AccessTokenProvider,
|
|
44
|
+
event_callback: EventCallback,
|
|
45
|
+
*,
|
|
46
|
+
reconnect_callback: ReconnectCallback | None = None,
|
|
47
|
+
token_refresh_callback: TokenRefreshCallback | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Initialize the event client without connecting it."""
|
|
50
|
+
self._session = session
|
|
51
|
+
self._url = url
|
|
52
|
+
self._access_token = access_token
|
|
53
|
+
self._event_callback = event_callback
|
|
54
|
+
self._reconnect_callback = reconnect_callback
|
|
55
|
+
self._token_refresh_callback = token_refresh_callback
|
|
56
|
+
self._stream: BeatbotEventStream | None = None
|
|
57
|
+
self._stopping = False
|
|
58
|
+
self._token_refresh_attempted = False
|
|
59
|
+
self._has_connected = False
|
|
60
|
+
self._connection_generation = 0
|
|
61
|
+
self._next_access_token: str | None = None
|
|
62
|
+
self._seen_event_ids: OrderedDict[str, None] = OrderedDict()
|
|
63
|
+
|
|
64
|
+
async def async_run(self) -> None:
|
|
65
|
+
"""Connect and receive events until closed or authentication fails."""
|
|
66
|
+
failures = 0
|
|
67
|
+
self._stopping = False
|
|
68
|
+
try:
|
|
69
|
+
while not self._stopping:
|
|
70
|
+
connection_generation = self._connection_generation
|
|
71
|
+
try:
|
|
72
|
+
await self._async_connect_and_receive()
|
|
73
|
+
except asyncio.CancelledError:
|
|
74
|
+
raise
|
|
75
|
+
except BeatbotConnectionReplacedError:
|
|
76
|
+
return
|
|
77
|
+
except BeatbotTokenRejectedError as err:
|
|
78
|
+
if (
|
|
79
|
+
self._token_refresh_attempted
|
|
80
|
+
or self._token_refresh_callback is None
|
|
81
|
+
):
|
|
82
|
+
raise BeatbotAuthenticationError(
|
|
83
|
+
"Event stream rejected the refreshed access token"
|
|
84
|
+
) from err
|
|
85
|
+
try:
|
|
86
|
+
token = self._token_refresh_callback(err.access_token)
|
|
87
|
+
if isawaitable(token):
|
|
88
|
+
token = await token
|
|
89
|
+
if not token:
|
|
90
|
+
raise BeatbotAuthenticationError(
|
|
91
|
+
"Token refresh returned no access token"
|
|
92
|
+
)
|
|
93
|
+
except BeatbotAuthenticationError:
|
|
94
|
+
raise
|
|
95
|
+
except BeatbotConnectionError as refresh_err:
|
|
96
|
+
failures += 1
|
|
97
|
+
_LOGGER.warning(
|
|
98
|
+
"Beatbot OAuth token refresh failed: %s", refresh_err
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
self._next_access_token = token
|
|
102
|
+
self._token_refresh_attempted = True
|
|
103
|
+
failures = 0
|
|
104
|
+
continue
|
|
105
|
+
except BeatbotAuthenticationError:
|
|
106
|
+
raise
|
|
107
|
+
except (
|
|
108
|
+
TimeoutError,
|
|
109
|
+
BeatbotConnectionError,
|
|
110
|
+
ClientError,
|
|
111
|
+
ConnectionError,
|
|
112
|
+
) as err:
|
|
113
|
+
if self._connection_generation != connection_generation:
|
|
114
|
+
failures = 0
|
|
115
|
+
failures += 1
|
|
116
|
+
_LOGGER.warning("Beatbot event stream disconnected: %s", err)
|
|
117
|
+
except Exception:
|
|
118
|
+
failures += 1
|
|
119
|
+
_LOGGER.exception("Unexpected Beatbot event stream failure")
|
|
120
|
+
|
|
121
|
+
if self._stopping:
|
|
122
|
+
return
|
|
123
|
+
delay = _RECONNECT_DELAYS[min(failures - 1, len(_RECONNECT_DELAYS) - 1)]
|
|
124
|
+
delay *= random.uniform(
|
|
125
|
+
1.0 - _RECONNECT_JITTER, 1.0 + _RECONNECT_JITTER
|
|
126
|
+
)
|
|
127
|
+
await asyncio.sleep(delay)
|
|
128
|
+
finally:
|
|
129
|
+
await self.async_close()
|
|
130
|
+
|
|
131
|
+
async def _async_connect_and_receive(self) -> None:
|
|
132
|
+
"""Connect once and receive until the connection closes."""
|
|
133
|
+
access_token = self._next_access_token
|
|
134
|
+
self._next_access_token = None
|
|
135
|
+
if access_token is None:
|
|
136
|
+
access_token = self._access_token()
|
|
137
|
+
if isawaitable(access_token):
|
|
138
|
+
access_token = await access_token
|
|
139
|
+
if not access_token:
|
|
140
|
+
raise BeatbotAuthenticationError("Missing OAuth access token")
|
|
141
|
+
|
|
142
|
+
stream = BeatbotEventStream(self._session, self._url, access_token)
|
|
143
|
+
self._stream = stream
|
|
144
|
+
try:
|
|
145
|
+
await stream.connect()
|
|
146
|
+
is_reconnect = self._has_connected
|
|
147
|
+
self._has_connected = True
|
|
148
|
+
self._connection_generation += 1
|
|
149
|
+
if is_reconnect and self._reconnect_callback is not None:
|
|
150
|
+
result = self._reconnect_callback()
|
|
151
|
+
if isawaitable(result):
|
|
152
|
+
await result
|
|
153
|
+
while not self._stopping:
|
|
154
|
+
try:
|
|
155
|
+
event = await stream.receive()
|
|
156
|
+
except BeatbotEventError as err:
|
|
157
|
+
_LOGGER.warning("Ignoring malformed Beatbot event: %s", err)
|
|
158
|
+
continue
|
|
159
|
+
self._token_refresh_attempted = False
|
|
160
|
+
if event.event_id in self._seen_event_ids:
|
|
161
|
+
continue
|
|
162
|
+
self._remember_event(event.event_id)
|
|
163
|
+
result = self._event_callback(event)
|
|
164
|
+
if isawaitable(result):
|
|
165
|
+
await result
|
|
166
|
+
finally:
|
|
167
|
+
await stream.close()
|
|
168
|
+
if self._stream is stream:
|
|
169
|
+
self._stream = None
|
|
170
|
+
|
|
171
|
+
async def async_close(self) -> None:
|
|
172
|
+
"""Stop receiving and close the current connection."""
|
|
173
|
+
self._stopping = True
|
|
174
|
+
stream, self._stream = self._stream, None
|
|
175
|
+
if stream is not None:
|
|
176
|
+
await stream.close()
|
|
177
|
+
|
|
178
|
+
def _remember_event(self, event_id: str) -> None:
|
|
179
|
+
"""Remember an event identifier for bounded duplicate suppression."""
|
|
180
|
+
self._seen_event_ids[event_id] = None
|
|
181
|
+
self._seen_event_ids.move_to_end(event_id)
|
|
182
|
+
while len(self._seen_event_ids) > _EVENT_DEDUP_CACHE_SIZE:
|
|
183
|
+
self._seen_event_ids.popitem(last=False)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Typed Beatbot cloud models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .const import (
|
|
9
|
+
INTERFACE_CHILD_LOCK,
|
|
10
|
+
INTERFACE_SENSOR_ERROR,
|
|
11
|
+
INTERFACE_VACUUM_BATTERY,
|
|
12
|
+
INTERFACE_VACUUM_STATE,
|
|
13
|
+
INTERFACE_VOICE_DISTURB,
|
|
14
|
+
INTERFACE_WORK_MODE,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class FirmwareVersion:
|
|
20
|
+
"""A device firmware version."""
|
|
21
|
+
|
|
22
|
+
channel: int
|
|
23
|
+
version: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class BeatbotCapability:
|
|
28
|
+
"""A Home Assistant capability advertised by Beatbot discovery."""
|
|
29
|
+
|
|
30
|
+
interface_info: str
|
|
31
|
+
retrievable: bool = False
|
|
32
|
+
proactively_reported: bool = False
|
|
33
|
+
non_controllable: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class BeatbotDeviceData:
|
|
38
|
+
"""A discovered Beatbot device."""
|
|
39
|
+
|
|
40
|
+
device_id: str
|
|
41
|
+
product_id: str
|
|
42
|
+
product_category: str
|
|
43
|
+
work_status: int
|
|
44
|
+
work_mode: int
|
|
45
|
+
error_code: int
|
|
46
|
+
battery_level: int
|
|
47
|
+
versions: list[FirmwareVersion]
|
|
48
|
+
is_online: bool
|
|
49
|
+
child_lock: bool = False
|
|
50
|
+
voice_disturb: bool = False
|
|
51
|
+
name: str = ""
|
|
52
|
+
model: str = ""
|
|
53
|
+
work_mode_options: dict[int, str] = field(default_factory=dict)
|
|
54
|
+
capabilities: dict[str, BeatbotCapability] = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
def apply_state(
|
|
57
|
+
self, states: dict[str, Any] | None, is_online: bool | None = None
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Apply a runtime-state response or event to this device."""
|
|
60
|
+
if states:
|
|
61
|
+
if INTERFACE_VACUUM_STATE in states:
|
|
62
|
+
self.work_status = states[INTERFACE_VACUUM_STATE]
|
|
63
|
+
if INTERFACE_VACUUM_BATTERY in states:
|
|
64
|
+
self.battery_level = states[INTERFACE_VACUUM_BATTERY]
|
|
65
|
+
if INTERFACE_SENSOR_ERROR in states:
|
|
66
|
+
self.error_code = states[INTERFACE_SENSOR_ERROR]
|
|
67
|
+
if INTERFACE_WORK_MODE in states:
|
|
68
|
+
self.work_mode = states[INTERFACE_WORK_MODE]
|
|
69
|
+
if INTERFACE_CHILD_LOCK in states:
|
|
70
|
+
self.child_lock = states[INTERFACE_CHILD_LOCK]
|
|
71
|
+
if INTERFACE_VOICE_DISTURB in states:
|
|
72
|
+
self.voice_disturb = states[INTERFACE_VOICE_DISTURB]
|
|
73
|
+
if is_online is not None:
|
|
74
|
+
self.is_online = is_online
|
|
75
|
+
|
|
76
|
+
def copy_runtime_state_from(self, other: BeatbotDeviceData) -> None:
|
|
77
|
+
"""Copy the last-known runtime state from another instance."""
|
|
78
|
+
self.work_status = other.work_status
|
|
79
|
+
self.work_mode = other.work_mode
|
|
80
|
+
self.error_code = other.error_code
|
|
81
|
+
self.battery_level = other.battery_level
|
|
82
|
+
self.is_online = other.is_online
|
|
83
|
+
self.child_lock = other.child_lock
|
|
84
|
+
self.voice_disturb = other.voice_disturb
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class BeatbotEvent:
|
|
89
|
+
"""A validated Beatbot cloud event envelope."""
|
|
90
|
+
|
|
91
|
+
event_id: str
|
|
92
|
+
event_type: str
|
|
93
|
+
device_id: str
|
|
94
|
+
payload: dict[str, Any] | None
|
|
95
|
+
|
|
96
|
+
def apply_to(self, device: BeatbotDeviceData) -> bool:
|
|
97
|
+
"""Apply a state-bearing event and return whether data changed."""
|
|
98
|
+
if self.event_type == "properties_changed":
|
|
99
|
+
assert self.payload is not None
|
|
100
|
+
device.apply_state({self.payload["interfaceInfo"]: self.payload["value"]})
|
|
101
|
+
return True
|
|
102
|
+
if self.event_type == "status":
|
|
103
|
+
assert self.payload is not None
|
|
104
|
+
device.apply_state(None, self.payload["online"])
|
|
105
|
+
return True
|
|
106
|
+
return False
|
|
@@ -205,6 +205,18 @@ def status_for(category: ProductCategory, raw_status: int) -> DeviceStatus | Non
|
|
|
205
205
|
return STATUS_BY_CATEGORY.get(category, {}).get(raw_status)
|
|
206
206
|
|
|
207
207
|
|
|
208
|
+
def error_for(category: ProductCategory, error_code: int) -> DeviceError | None:
|
|
209
|
+
"""Return the first active error for a product category."""
|
|
210
|
+
return next(
|
|
211
|
+
(
|
|
212
|
+
error
|
|
213
|
+
for error, bit in ERROR_BITS_BY_CATEGORY.get(category, ())
|
|
214
|
+
if error_code & bit
|
|
215
|
+
),
|
|
216
|
+
None,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
208
220
|
def error_mask_for(category: ProductCategory, *, include_notices: bool = True) -> int:
|
|
209
221
|
"""Return the combined error mask for a product category."""
|
|
210
222
|
return sum(
|
|
@@ -106,6 +106,8 @@ class BeatbotEventStream:
|
|
|
106
106
|
BeatbotEventStream._validate_property_payload(payload)
|
|
107
107
|
elif event_type == "status" and not isinstance(payload.get("online"), bool):
|
|
108
108
|
raise BeatbotEventError("Status event has an invalid online value")
|
|
109
|
+
elif event_type == "device_added" and payload.get("deviceId") != device_id:
|
|
110
|
+
raise BeatbotEventError("device_added payload has a mismatched deviceId")
|
|
109
111
|
return BeatbotEvent(event_id, event_type, device_id, payload)
|
|
110
112
|
|
|
111
113
|
@staticmethod
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: beatbot-cloud
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Asynchronous Python client for the Beatbot cloud API
|
|
5
5
|
Author: Beatbot Robotics
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
17
|
Classifier: Typing :: Typed
|
|
17
18
|
Requires-Python: >=3.11
|
|
18
19
|
Description-Content-Type: text/markdown
|
|
@@ -32,15 +33,30 @@ It provides region-aware REST access, typed device models, and a WebSocket event
|
|
|
32
33
|
transport without depending on Home Assistant.
|
|
33
34
|
|
|
34
35
|
```python
|
|
35
|
-
from beatbot_cloud import BeatbotClient
|
|
36
|
+
from beatbot_cloud import BeatbotClient, BeatbotEventClient
|
|
36
37
|
|
|
37
|
-
client = BeatbotClient(
|
|
38
|
+
client = BeatbotClient(
|
|
39
|
+
region="na",
|
|
40
|
+
session=session,
|
|
41
|
+
access_token=async_get_access_token,
|
|
42
|
+
)
|
|
38
43
|
devices = await client.get_devices()
|
|
44
|
+
|
|
45
|
+
events = BeatbotEventClient(
|
|
46
|
+
session,
|
|
47
|
+
client.event_stream_url,
|
|
48
|
+
client.async_get_access_token,
|
|
49
|
+
async_handle_event,
|
|
50
|
+
reconnect_callback=async_handle_reconnect,
|
|
51
|
+
token_refresh_callback=async_refresh_access_token,
|
|
52
|
+
)
|
|
53
|
+
await events.async_run()
|
|
39
54
|
```
|
|
40
55
|
|
|
41
|
-
The caller
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
The caller provides an access token or a synchronous/asynchronous token provider.
|
|
57
|
+
The library owns REST request construction and WebSocket reconnection; applications
|
|
58
|
+
can register callbacks for events, successful reconnections, and rejected-token
|
|
59
|
+
refreshes.
|
|
44
60
|
|
|
45
61
|
## Development
|
|
46
62
|
|
|
@@ -51,4 +67,3 @@ ruff check .
|
|
|
51
67
|
ruff format --check .
|
|
52
68
|
python -m build
|
|
53
69
|
```
|
|
54
|
-
|
|
@@ -2,8 +2,10 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/beatbot_cloud/__init__.py
|
|
5
|
+
src/beatbot_cloud/auth.py
|
|
5
6
|
src/beatbot_cloud/client.py
|
|
6
7
|
src/beatbot_cloud/const.py
|
|
8
|
+
src/beatbot_cloud/event_client.py
|
|
7
9
|
src/beatbot_cloud/exceptions.py
|
|
8
10
|
src/beatbot_cloud/models.py
|
|
9
11
|
src/beatbot_cloud/protocol.py
|
|
@@ -14,6 +16,8 @@ src/beatbot_cloud.egg-info/SOURCES.txt
|
|
|
14
16
|
src/beatbot_cloud.egg-info/dependency_links.txt
|
|
15
17
|
src/beatbot_cloud.egg-info/requires.txt
|
|
16
18
|
src/beatbot_cloud.egg-info/top_level.txt
|
|
19
|
+
tests/test_auth.py
|
|
17
20
|
tests/test_client.py
|
|
21
|
+
tests/test_event_client.py
|
|
18
22
|
tests/test_protocol.py
|
|
19
23
|
tests/test_websocket.py
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Tests for Beatbot authentication helpers."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from beatbot_cloud import decode_access_token
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_decode_access_token() -> None:
|
|
12
|
+
"""Decode valid JWT claims."""
|
|
13
|
+
payload = (
|
|
14
|
+
base64.urlsafe_b64encode(json.dumps({"sub": "user-1", "region": "eu"}).encode())
|
|
15
|
+
.decode()
|
|
16
|
+
.rstrip("=")
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
assert decode_access_token(f"header.{payload}.signature") == {
|
|
20
|
+
"sub": "user-1",
|
|
21
|
+
"region": "eu",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.parametrize("token", ["", "invalid", "a.!.c", "a.W10.c"])
|
|
26
|
+
def test_decode_access_token_rejects_invalid_claims(token: str) -> None:
|
|
27
|
+
"""Reject malformed tokens and non-object claims."""
|
|
28
|
+
assert decode_access_token(token) is None
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
-
from types import SimpleNamespace
|
|
7
6
|
|
|
8
7
|
import pytest
|
|
9
|
-
from aiohttp import
|
|
8
|
+
from aiohttp import ClientError
|
|
10
9
|
|
|
11
10
|
from beatbot_cloud import (
|
|
12
11
|
BeatbotAuthenticationError,
|
|
13
12
|
BeatbotClient,
|
|
14
13
|
BeatbotConnectionError,
|
|
14
|
+
BeatbotEvent,
|
|
15
15
|
)
|
|
16
|
-
from beatbot_cloud.const import
|
|
16
|
+
from beatbot_cloud.const import REGION_API_BASE_URL
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class Response:
|
|
@@ -28,23 +28,23 @@ class Response:
|
|
|
28
28
|
return self.body
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
class
|
|
31
|
+
class Session:
|
|
32
32
|
"""Record requests and return or raise a configured result."""
|
|
33
33
|
|
|
34
34
|
def __init__(self, result):
|
|
35
35
|
self.result = result
|
|
36
36
|
self.calls = []
|
|
37
37
|
|
|
38
|
-
async def
|
|
38
|
+
async def request(self, method, url, **kwargs):
|
|
39
39
|
self.calls.append((method, url, kwargs))
|
|
40
40
|
if isinstance(self.result, Exception):
|
|
41
41
|
raise self.result
|
|
42
42
|
return self.result
|
|
43
43
|
|
|
44
44
|
|
|
45
|
-
def client(result, region="na"):
|
|
46
|
-
|
|
47
|
-
return BeatbotClient(region,
|
|
45
|
+
def client(result, region="na", access_token="access-token"):
|
|
46
|
+
session = Session(result)
|
|
47
|
+
return BeatbotClient(region, session, access_token), session
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def envelope(data=None, *, code=200, message=None):
|
|
@@ -97,42 +97,40 @@ async def test_request_rejects_invalid_envelope(response, message):
|
|
|
97
97
|
|
|
98
98
|
|
|
99
99
|
async def test_request_forwards_options_and_returns_data():
|
|
100
|
-
api,
|
|
100
|
+
api, session = client(envelope({"ok": True}))
|
|
101
101
|
result = await api._request(
|
|
102
102
|
"POST", "/test", params={"a": "b"}, json_body={"value": 1}
|
|
103
103
|
)
|
|
104
104
|
assert result == {"ok": True}
|
|
105
|
-
method, url, kwargs =
|
|
105
|
+
method, url, kwargs = session.calls[0]
|
|
106
106
|
assert method == "POST"
|
|
107
107
|
assert url.endswith("/test")
|
|
108
108
|
assert kwargs["params"] == {"a": "b"}
|
|
109
109
|
assert kwargs["json"] == {"value": 1}
|
|
110
|
+
assert kwargs["headers"]["Authorization"] == "Bearer access-token"
|
|
110
111
|
|
|
111
112
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
SimpleNamespace(real_url=OAUTH2_TOKEN_URL), (), status=status
|
|
116
|
-
)
|
|
117
|
-
api, _ = client(error)
|
|
118
|
-
with pytest.raises(BeatbotAuthenticationError):
|
|
113
|
+
async def test_client_error_is_connection_error():
|
|
114
|
+
api, _ = client(ClientError("offline"))
|
|
115
|
+
with pytest.raises(BeatbotConnectionError, match="offline"):
|
|
119
116
|
await api._request("GET", "/test")
|
|
120
117
|
|
|
121
118
|
|
|
122
|
-
|
|
123
|
-
async def
|
|
124
|
-
|
|
125
|
-
SimpleNamespace(real_url=OAUTH2_TOKEN_URL), (), status=status
|
|
126
|
-
)
|
|
127
|
-
api, _ = client(error)
|
|
128
|
-
with pytest.raises(BeatbotConnectionError):
|
|
129
|
-
await api._request("GET", "/test")
|
|
119
|
+
async def test_async_access_token_provider():
|
|
120
|
+
async def access_token():
|
|
121
|
+
return "rotated-token"
|
|
130
122
|
|
|
123
|
+
api, session = client(envelope(), access_token=access_token)
|
|
131
124
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
125
|
+
await api.get_devices()
|
|
126
|
+
|
|
127
|
+
assert session.calls[0][2]["headers"]["Authorization"] == "Bearer rotated-token"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def test_missing_access_token_requires_authentication():
|
|
131
|
+
api, _ = client(envelope(), access_token="")
|
|
132
|
+
with pytest.raises(BeatbotAuthenticationError, match="Missing"):
|
|
133
|
+
await api.get_devices()
|
|
136
134
|
|
|
137
135
|
|
|
138
136
|
async def test_get_devices_empty():
|
|
@@ -188,6 +186,57 @@ async def test_get_devices_parses_models_and_capabilities():
|
|
|
188
186
|
assert devices[0].capabilities["select.work_mode"].retrievable
|
|
189
187
|
|
|
190
188
|
|
|
189
|
+
def test_device_applies_and_copies_runtime_state():
|
|
190
|
+
"""Runtime state is parsed by the library model without dynamic attributes."""
|
|
191
|
+
api, _ = client(envelope())
|
|
192
|
+
device = api._parse_device({"deviceId": "d1"})
|
|
193
|
+
previous = api._parse_device({"deviceId": "d1", "isOnline": True})
|
|
194
|
+
assert device is not None
|
|
195
|
+
assert previous is not None
|
|
196
|
+
previous.apply_state(
|
|
197
|
+
{
|
|
198
|
+
"vacuum.state": 5,
|
|
199
|
+
"vacuum.battery": 80,
|
|
200
|
+
"sensor.error": 2,
|
|
201
|
+
"select.work_mode": 1,
|
|
202
|
+
"switch.child_lock": True,
|
|
203
|
+
"switch.voice_disturb": True,
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
device.copy_runtime_state_from(previous)
|
|
207
|
+
|
|
208
|
+
assert device.work_status == 5
|
|
209
|
+
assert device.battery_level == 80
|
|
210
|
+
assert device.error_code == 2
|
|
211
|
+
assert device.work_mode == 1
|
|
212
|
+
assert device.child_lock
|
|
213
|
+
assert device.voice_disturb
|
|
214
|
+
assert device.is_online
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_state_events_apply_to_device():
|
|
218
|
+
"""State-bearing events interpret their payload in the library."""
|
|
219
|
+
api, _ = client(envelope())
|
|
220
|
+
device = api._parse_device({"deviceId": "d1", "isOnline": True})
|
|
221
|
+
assert device is not None
|
|
222
|
+
|
|
223
|
+
changed = BeatbotEvent(
|
|
224
|
+
"1",
|
|
225
|
+
"properties_changed",
|
|
226
|
+
"d1",
|
|
227
|
+
{"interfaceInfo": "vacuum.battery", "value": 42},
|
|
228
|
+
).apply_to(device)
|
|
229
|
+
online_changed = BeatbotEvent("2", "status", "d1", {"online": False}).apply_to(
|
|
230
|
+
device
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
assert changed
|
|
234
|
+
assert online_changed
|
|
235
|
+
assert device.battery_level == 42
|
|
236
|
+
assert not device.is_online
|
|
237
|
+
assert not BeatbotEvent("3", "unknown", "d1", {}).apply_to(device)
|
|
238
|
+
|
|
239
|
+
|
|
191
240
|
@pytest.mark.parametrize("configuration", ["bad json", [], None])
|
|
192
241
|
def test_invalid_work_mode_configuration(configuration):
|
|
193
242
|
assert (
|
|
@@ -234,11 +283,11 @@ async def test_get_device_state():
|
|
|
234
283
|
|
|
235
284
|
|
|
236
285
|
async def test_actions():
|
|
237
|
-
api,
|
|
286
|
+
api, session = client(envelope())
|
|
238
287
|
await api.send_action("d1", "vacuum.start")
|
|
239
288
|
await api.set_work_mode("d1", "quick")
|
|
240
289
|
await api.set_switch("d1", "switch.child_lock", "on")
|
|
241
|
-
assert [call[2]["json"] for call in
|
|
290
|
+
assert [call[2]["json"] for call in session.calls] == [
|
|
242
291
|
{"interfaceInfo": "vacuum.start"},
|
|
243
292
|
{"interfaceInfo": "select.work_mode", "label": "quick"},
|
|
244
293
|
{"interfaceInfo": "switch.child_lock", "label": "on"},
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Tests for the resilient Beatbot event client."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
from unittest.mock import AsyncMock, Mock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from beatbot_cloud import (
|
|
10
|
+
BeatbotAuthenticationError,
|
|
11
|
+
BeatbotConnectionError,
|
|
12
|
+
BeatbotConnectionReplacedError,
|
|
13
|
+
BeatbotEvent,
|
|
14
|
+
BeatbotEventClient,
|
|
15
|
+
BeatbotEventError,
|
|
16
|
+
BeatbotTokenRejectedError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def client(**kwargs):
|
|
21
|
+
return BeatbotEventClient(
|
|
22
|
+
SimpleNamespace(),
|
|
23
|
+
"wss://test",
|
|
24
|
+
kwargs.pop("access_token", lambda: "token"),
|
|
25
|
+
kwargs.pop("event_callback", Mock()),
|
|
26
|
+
**kwargs,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def test_connect_delivers_unique_events_and_ignores_malformed():
|
|
31
|
+
event_callback = Mock()
|
|
32
|
+
event = BeatbotEvent("event-1", "status", "device-1", {"online": True})
|
|
33
|
+
stream = SimpleNamespace(
|
|
34
|
+
connect=AsyncMock(),
|
|
35
|
+
receive=AsyncMock(
|
|
36
|
+
side_effect=[BeatbotEventError("bad"), event, event, asyncio.CancelledError]
|
|
37
|
+
),
|
|
38
|
+
close=AsyncMock(),
|
|
39
|
+
)
|
|
40
|
+
event_client = client(event_callback=event_callback)
|
|
41
|
+
|
|
42
|
+
with (
|
|
43
|
+
patch("beatbot_cloud.event_client.BeatbotEventStream", return_value=stream),
|
|
44
|
+
pytest.raises(asyncio.CancelledError),
|
|
45
|
+
):
|
|
46
|
+
await event_client._async_connect_and_receive()
|
|
47
|
+
|
|
48
|
+
event_callback.assert_called_once_with(event)
|
|
49
|
+
stream.close.assert_awaited_once()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def test_reconnect_callback_runs_after_first_connection():
|
|
53
|
+
reconnect_callback = AsyncMock()
|
|
54
|
+
event_client = client(reconnect_callback=reconnect_callback)
|
|
55
|
+
event_client._has_connected = True
|
|
56
|
+
stream = SimpleNamespace(
|
|
57
|
+
connect=AsyncMock(),
|
|
58
|
+
receive=AsyncMock(side_effect=asyncio.CancelledError),
|
|
59
|
+
close=AsyncMock(),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
with (
|
|
63
|
+
patch("beatbot_cloud.event_client.BeatbotEventStream", return_value=stream),
|
|
64
|
+
pytest.raises(asyncio.CancelledError),
|
|
65
|
+
):
|
|
66
|
+
await event_client._async_connect_and_receive()
|
|
67
|
+
|
|
68
|
+
reconnect_callback.assert_awaited_once()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def test_token_rejection_refreshes_and_reconnects():
|
|
72
|
+
refresh_callback = AsyncMock(return_value="new-token")
|
|
73
|
+
event_client = client(token_refresh_callback=refresh_callback)
|
|
74
|
+
event_client._async_connect_and_receive = AsyncMock(
|
|
75
|
+
side_effect=[
|
|
76
|
+
BeatbotTokenRejectedError("old-token"),
|
|
77
|
+
BeatbotConnectionReplacedError(),
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
await event_client.async_run()
|
|
82
|
+
|
|
83
|
+
refresh_callback.assert_awaited_once_with("old-token")
|
|
84
|
+
assert event_client._next_access_token == "new-token"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def test_repeated_token_rejection_requires_authentication():
|
|
88
|
+
event_client = client(token_refresh_callback=AsyncMock(return_value="new-token"))
|
|
89
|
+
event_client._token_refresh_attempted = True
|
|
90
|
+
event_client._async_connect_and_receive = AsyncMock(
|
|
91
|
+
side_effect=BeatbotTokenRejectedError("new-token")
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
with pytest.raises(BeatbotAuthenticationError):
|
|
95
|
+
await event_client.async_run()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def test_connection_failure_retries():
|
|
99
|
+
event_client = client()
|
|
100
|
+
event_client._async_connect_and_receive = AsyncMock(
|
|
101
|
+
side_effect=[
|
|
102
|
+
BeatbotConnectionError("offline"),
|
|
103
|
+
BeatbotConnectionReplacedError(),
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
with patch("beatbot_cloud.event_client.asyncio.sleep", AsyncMock()) as sleep:
|
|
108
|
+
await event_client.async_run()
|
|
109
|
+
|
|
110
|
+
sleep.assert_awaited_once()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def test_close_is_idempotent():
|
|
114
|
+
event_client = client()
|
|
115
|
+
stream = SimpleNamespace(close=AsyncMock())
|
|
116
|
+
event_client._stream = stream
|
|
117
|
+
|
|
118
|
+
await event_client.async_close()
|
|
119
|
+
await event_client.async_close()
|
|
120
|
+
|
|
121
|
+
stream.close.assert_awaited_once()
|
|
@@ -4,6 +4,7 @@ from beatbot_cloud import (
|
|
|
4
4
|
DeviceError,
|
|
5
5
|
DeviceStatus,
|
|
6
6
|
ProductCategory,
|
|
7
|
+
error_for,
|
|
7
8
|
error_mask_for,
|
|
8
9
|
status_for,
|
|
9
10
|
)
|
|
@@ -25,3 +26,11 @@ def test_clean_base_error_mask_can_exclude_notices():
|
|
|
25
26
|
assert faults_only & (1 << 0)
|
|
26
27
|
assert not faults_only & (1 << 5)
|
|
27
28
|
assert DeviceError.SELF_ERR_SPRAY.value == "self_err_spray"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_error_mapping_returns_first_active_error():
|
|
32
|
+
assert (
|
|
33
|
+
error_for(ProductCategory.POOL_CLEAN_BOT, (1 << 2) | (1 << 6))
|
|
34
|
+
is DeviceError.POWER_LOW
|
|
35
|
+
)
|
|
36
|
+
assert error_for(ProductCategory.POOL_CLEAN_BOT, 0) is None
|
|
@@ -85,6 +85,14 @@ def test_parse_removed_event():
|
|
|
85
85
|
assert event.payload is None
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def test_parse_rejects_mismatched_added_device():
|
|
89
|
+
with pytest.raises(BeatbotEventError, match="mismatched"):
|
|
90
|
+
BeatbotEventStream.parse_event(
|
|
91
|
+
'{"eventId":"1","type":"device_added","deviceId":"d",'
|
|
92
|
+
'"payload":{"deviceId":"other"}}'
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
88
96
|
async def test_connect_and_close():
|
|
89
97
|
websocket = SimpleNamespace(closed=False, close=AsyncMock())
|
|
90
98
|
session = SimpleNamespace(ws_connect=AsyncMock(return_value=websocket))
|
beatbot_cloud-0.2.0/README.md
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# beatbot-cloud
|
|
2
|
-
|
|
3
|
-
`beatbot-cloud` is the asynchronous Python client for Beatbot cloud accounts.
|
|
4
|
-
It provides region-aware REST access, typed device models, and a WebSocket event
|
|
5
|
-
transport without depending on Home Assistant.
|
|
6
|
-
|
|
7
|
-
```python
|
|
8
|
-
from beatbot_cloud import BeatbotClient
|
|
9
|
-
|
|
10
|
-
client = BeatbotClient(region="na", requester=oauth_request)
|
|
11
|
-
devices = await client.get_devices()
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
The caller owns authentication. `requester` is an async callable compatible
|
|
15
|
-
with `aiohttp.ClientSession.request`; it may add or refresh OAuth credentials
|
|
16
|
-
before forwarding the request.
|
|
17
|
-
|
|
18
|
-
## Development
|
|
19
|
-
|
|
20
|
-
```bash
|
|
21
|
-
python -m pip install -e '.[test]'
|
|
22
|
-
pytest
|
|
23
|
-
ruff check .
|
|
24
|
-
ruff format --check .
|
|
25
|
-
python -m build
|
|
26
|
-
```
|
|
27
|
-
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
"""Typed Beatbot cloud models."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass, field
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@dataclass(slots=True)
|
|
10
|
-
class FirmwareVersion:
|
|
11
|
-
"""A device firmware version."""
|
|
12
|
-
|
|
13
|
-
channel: int
|
|
14
|
-
version: str
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@dataclass(slots=True)
|
|
18
|
-
class BeatbotCapability:
|
|
19
|
-
"""A Home Assistant capability advertised by Beatbot discovery."""
|
|
20
|
-
|
|
21
|
-
interface_info: str
|
|
22
|
-
retrievable: bool = False
|
|
23
|
-
proactively_reported: bool = False
|
|
24
|
-
non_controllable: bool = False
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@dataclass(slots=True)
|
|
28
|
-
class BeatbotDeviceData:
|
|
29
|
-
"""A discovered Beatbot device."""
|
|
30
|
-
|
|
31
|
-
device_id: str
|
|
32
|
-
product_id: str
|
|
33
|
-
product_category: str
|
|
34
|
-
work_status: int
|
|
35
|
-
work_mode: int
|
|
36
|
-
error_code: int
|
|
37
|
-
battery_level: int
|
|
38
|
-
versions: list[FirmwareVersion]
|
|
39
|
-
is_online: bool
|
|
40
|
-
child_lock: bool = False
|
|
41
|
-
voice_disturb: bool = False
|
|
42
|
-
name: str = ""
|
|
43
|
-
model: str = ""
|
|
44
|
-
work_mode_options: dict[int, str] = field(default_factory=dict)
|
|
45
|
-
capabilities: dict[str, BeatbotCapability] = field(default_factory=dict)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
@dataclass(frozen=True, slots=True)
|
|
49
|
-
class BeatbotEvent:
|
|
50
|
-
"""A validated Beatbot cloud event envelope."""
|
|
51
|
-
|
|
52
|
-
event_id: str
|
|
53
|
-
event_type: str
|
|
54
|
-
device_id: str
|
|
55
|
-
payload: dict[str, Any] | None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|