beatbot-cloud 0.1.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.1.0/LICENSE +18 -0
- beatbot_cloud-0.1.0/PKG-INFO +54 -0
- beatbot_cloud-0.1.0/README.md +27 -0
- beatbot_cloud-0.1.0/pyproject.toml +57 -0
- beatbot_cloud-0.1.0/setup.cfg +4 -0
- beatbot_cloud-0.1.0/src/beatbot_cloud/__init__.py +24 -0
- beatbot_cloud-0.1.0/src/beatbot_cloud/client.py +285 -0
- beatbot_cloud-0.1.0/src/beatbot_cloud/const.py +23 -0
- beatbot_cloud-0.1.0/src/beatbot_cloud/exceptions.py +26 -0
- beatbot_cloud-0.1.0/src/beatbot_cloud/models.py +55 -0
- beatbot_cloud-0.1.0/src/beatbot_cloud/py.typed +1 -0
- beatbot_cloud-0.1.0/src/beatbot_cloud/websocket.py +114 -0
- beatbot_cloud-0.1.0/src/beatbot_cloud.egg-info/PKG-INFO +54 -0
- beatbot_cloud-0.1.0/src/beatbot_cloud.egg-info/SOURCES.txt +17 -0
- beatbot_cloud-0.1.0/src/beatbot_cloud.egg-info/dependency_links.txt +1 -0
- beatbot_cloud-0.1.0/src/beatbot_cloud.egg-info/requires.txt +7 -0
- beatbot_cloud-0.1.0/src/beatbot_cloud.egg-info/top_level.txt +1 -0
- beatbot_cloud-0.1.0/tests/test_client.py +245 -0
- beatbot_cloud-0.1.0/tests/test_websocket.py +124 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 Beatbot Robotics
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
|
18
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: beatbot-cloud
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchronous Python client for the Beatbot cloud API
|
|
5
|
+
Author: Beatbot Robotics
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://www.beatbot.com
|
|
8
|
+
Project-URL: Repository, https://github.com/Beatbot-Robotics/beatbot-cloud-python
|
|
9
|
+
Project-URL: Issues, https://github.com/Beatbot-Robotics/beatbot-cloud-python/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: aiohttp>=3.11.0
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: coverage[toml]>=7.6; extra == "test"
|
|
23
|
+
Requires-Dist: pytest>=8.3; extra == "test"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "test"
|
|
25
|
+
Requires-Dist: ruff>=0.15; extra == "test"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# beatbot-cloud
|
|
29
|
+
|
|
30
|
+
`beatbot-cloud` is the asynchronous Python client for Beatbot cloud accounts.
|
|
31
|
+
It provides region-aware REST access, typed device models, and a WebSocket event
|
|
32
|
+
transport without depending on Home Assistant.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from beatbot_cloud import BeatbotClient
|
|
36
|
+
|
|
37
|
+
client = BeatbotClient(region="na", requester=oauth_request)
|
|
38
|
+
devices = await client.get_devices()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The caller owns authentication. `requester` is an async callable compatible
|
|
42
|
+
with `aiohttp.ClientSession.request`; it may add or refresh OAuth credentials
|
|
43
|
+
before forwarding the request.
|
|
44
|
+
|
|
45
|
+
## Development
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python -m pip install -e '.[test]'
|
|
49
|
+
pytest
|
|
50
|
+
ruff check .
|
|
51
|
+
ruff format --check .
|
|
52
|
+
python -m build
|
|
53
|
+
```
|
|
54
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "beatbot-cloud"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Asynchronous Python client for the Beatbot cloud API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{ name = "Beatbot Robotics" }]
|
|
13
|
+
dependencies = ["aiohttp>=3.11.0"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://www.beatbot.com"
|
|
26
|
+
Repository = "https://github.com/Beatbot-Robotics/beatbot-cloud-python"
|
|
27
|
+
Issues = "https://github.com/Beatbot-Robotics/beatbot-cloud-python/issues"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
test = [
|
|
31
|
+
"coverage[toml]>=7.6",
|
|
32
|
+
"pytest>=8.3",
|
|
33
|
+
"pytest-asyncio>=0.24",
|
|
34
|
+
"ruff>=0.15",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
asyncio_mode = "auto"
|
|
39
|
+
addopts = "--strict-markers"
|
|
40
|
+
|
|
41
|
+
[tool.coverage.run]
|
|
42
|
+
branch = true
|
|
43
|
+
source = ["beatbot_cloud"]
|
|
44
|
+
|
|
45
|
+
[tool.coverage.report]
|
|
46
|
+
fail_under = 95
|
|
47
|
+
show_missing = true
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
target-version = "py311"
|
|
51
|
+
line-length = 88
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["ASYNC", "B", "E", "F", "I", "SIM", "UP"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint.per-file-ignores]
|
|
57
|
+
"tests/**" = ["S101"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Asynchronous client for the Beatbot cloud API."""
|
|
2
|
+
|
|
3
|
+
from .client import BeatbotClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
BeatbotAuthenticationError,
|
|
6
|
+
BeatbotConnectionError,
|
|
7
|
+
BeatbotConnectionReplacedError,
|
|
8
|
+
BeatbotTokenRejectedError,
|
|
9
|
+
)
|
|
10
|
+
from .models import BeatbotCapability, BeatbotDeviceData, BeatbotEvent, FirmwareVersion
|
|
11
|
+
from .websocket import BeatbotEventStream
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BeatbotAuthenticationError",
|
|
15
|
+
"BeatbotCapability",
|
|
16
|
+
"BeatbotClient",
|
|
17
|
+
"BeatbotConnectionError",
|
|
18
|
+
"BeatbotConnectionReplacedError",
|
|
19
|
+
"BeatbotDeviceData",
|
|
20
|
+
"BeatbotEvent",
|
|
21
|
+
"BeatbotEventStream",
|
|
22
|
+
"BeatbotTokenRejectedError",
|
|
23
|
+
"FirmwareVersion",
|
|
24
|
+
]
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Region-aware asynchronous Beatbot REST client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from http import HTTPStatus
|
|
9
|
+
from typing import Any, Protocol, TypeAlias
|
|
10
|
+
|
|
11
|
+
from aiohttp import ClientResponseError, ClientTimeout
|
|
12
|
+
|
|
13
|
+
from .const import (
|
|
14
|
+
DEVICE_ACTIONS_PATH,
|
|
15
|
+
DEVICE_STATES_PATH,
|
|
16
|
+
DEVICES_PATH,
|
|
17
|
+
EVENTS_PATH,
|
|
18
|
+
HTTP_API_TIMEOUT,
|
|
19
|
+
INTERFACE_WORK_MODE,
|
|
20
|
+
OAUTH2_TOKEN_URL,
|
|
21
|
+
REGION_API_BASE_URL,
|
|
22
|
+
RESULT_SUCCESS_CODE,
|
|
23
|
+
)
|
|
24
|
+
from .exceptions import BeatbotAuthenticationError, BeatbotConnectionError
|
|
25
|
+
from .models import BeatbotCapability, BeatbotDeviceData, FirmwareVersion
|
|
26
|
+
|
|
27
|
+
_LOGGER = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Response(Protocol):
|
|
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
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BeatbotClient:
|
|
54
|
+
"""Access the Beatbot cloud API using a caller-provided request function."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, region: str, requester: Requester) -> None:
|
|
57
|
+
"""Initialize the client for an OAuth token's region claim."""
|
|
58
|
+
try:
|
|
59
|
+
self._base_url = REGION_API_BASE_URL[region]
|
|
60
|
+
except KeyError as err:
|
|
61
|
+
raise ValueError(f"Unknown or missing Beatbot region: {region!r}") from err
|
|
62
|
+
self._requester = requester
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def event_stream_url(self) -> str:
|
|
66
|
+
"""Return the region-routed WebSocket endpoint."""
|
|
67
|
+
if self._base_url.startswith("https://"):
|
|
68
|
+
base_url = f"wss://{self._base_url.removeprefix('https://')}"
|
|
69
|
+
else:
|
|
70
|
+
base_url = self._base_url
|
|
71
|
+
return f"{base_url}{EVENTS_PATH}"
|
|
72
|
+
|
|
73
|
+
async def _request(
|
|
74
|
+
self,
|
|
75
|
+
method: str,
|
|
76
|
+
path: str,
|
|
77
|
+
*,
|
|
78
|
+
params: dict[str, str] | None = None,
|
|
79
|
+
json_body: Any | None = None,
|
|
80
|
+
) -> Any:
|
|
81
|
+
"""Request and validate a Beatbot result envelope."""
|
|
82
|
+
try:
|
|
83
|
+
response = await self._requester(
|
|
84
|
+
method,
|
|
85
|
+
f"{self._base_url}{path}",
|
|
86
|
+
params=params,
|
|
87
|
+
json=json_body,
|
|
88
|
+
headers={"Accept": "application/json"},
|
|
89
|
+
timeout=ClientTimeout(total=HTTP_API_TIMEOUT),
|
|
90
|
+
)
|
|
91
|
+
except ClientResponseError as err:
|
|
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:
|
|
98
|
+
raise BeatbotConnectionError(str(err)) from err
|
|
99
|
+
|
|
100
|
+
body = await response.text()
|
|
101
|
+
if response.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
|
|
102
|
+
raise BeatbotAuthenticationError(f"Unauthorized: {response.status}")
|
|
103
|
+
if response.status >= HTTPStatus.BAD_REQUEST:
|
|
104
|
+
raise BeatbotConnectionError(f"API request failed: {response.status}")
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
payload = json.loads(body)
|
|
108
|
+
except (json.JSONDecodeError, TypeError) as err:
|
|
109
|
+
content_type = response.headers.get("Content-Type", "unknown")
|
|
110
|
+
_LOGGER.warning(
|
|
111
|
+
"Beatbot API returned non-JSON response (%s, %s)",
|
|
112
|
+
response.status,
|
|
113
|
+
content_type,
|
|
114
|
+
)
|
|
115
|
+
raise BeatbotConnectionError(
|
|
116
|
+
f"API returned non-JSON response ({response.status}, {content_type})"
|
|
117
|
+
) from err
|
|
118
|
+
|
|
119
|
+
if not isinstance(payload, dict):
|
|
120
|
+
raise BeatbotConnectionError("API returned an invalid response envelope")
|
|
121
|
+
if payload.get("code") != RESULT_SUCCESS_CODE:
|
|
122
|
+
raise BeatbotConnectionError(
|
|
123
|
+
f"API error {payload.get('code')}: {payload.get('message')}"
|
|
124
|
+
)
|
|
125
|
+
return payload.get("data")
|
|
126
|
+
|
|
127
|
+
async def get_devices(self) -> list[BeatbotDeviceData]:
|
|
128
|
+
"""Return devices discovered for the account."""
|
|
129
|
+
raw = await self._request("GET", DEVICES_PATH)
|
|
130
|
+
if not raw:
|
|
131
|
+
return []
|
|
132
|
+
if isinstance(raw, str):
|
|
133
|
+
try:
|
|
134
|
+
discovery = json.loads(raw)
|
|
135
|
+
except (json.JSONDecodeError, TypeError) as err:
|
|
136
|
+
raise BeatbotConnectionError(
|
|
137
|
+
f"Invalid discovery payload: {err}"
|
|
138
|
+
) from err
|
|
139
|
+
else:
|
|
140
|
+
discovery = raw
|
|
141
|
+
|
|
142
|
+
devices = (discovery or {}).get("devices") or []
|
|
143
|
+
return [
|
|
144
|
+
parsed
|
|
145
|
+
for device in devices
|
|
146
|
+
if (parsed := self._parse_device(device)) is not None
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _parse_device(device: dict[str, Any]) -> BeatbotDeviceData | None:
|
|
151
|
+
"""Parse a discovery device, ignoring entries without an ID."""
|
|
152
|
+
device_id = device.get("deviceId") or ""
|
|
153
|
+
if not device_id:
|
|
154
|
+
return None
|
|
155
|
+
versions = [
|
|
156
|
+
FirmwareVersion(
|
|
157
|
+
channel=item.get("channel", 0), version=item.get("version") or ""
|
|
158
|
+
)
|
|
159
|
+
for item in (device.get("versions") or [])
|
|
160
|
+
if isinstance(item, dict)
|
|
161
|
+
]
|
|
162
|
+
capabilities = device.get("capabilities")
|
|
163
|
+
return BeatbotDeviceData(
|
|
164
|
+
device_id=device_id,
|
|
165
|
+
product_id=device.get("productId") or "",
|
|
166
|
+
product_category=device.get("productCategory") or "",
|
|
167
|
+
name=device.get("name") or "",
|
|
168
|
+
model=device.get("model") or "",
|
|
169
|
+
work_status=0,
|
|
170
|
+
work_mode=0,
|
|
171
|
+
error_code=0,
|
|
172
|
+
battery_level=0,
|
|
173
|
+
versions=versions,
|
|
174
|
+
is_online=bool(device.get("isOnline", False)),
|
|
175
|
+
work_mode_options=BeatbotClient._parse_work_mode_options(capabilities),
|
|
176
|
+
capabilities=BeatbotClient._parse_capabilities(capabilities),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _parse_work_mode_options(
|
|
181
|
+
capabilities: list[dict[str, Any]] | None,
|
|
182
|
+
) -> dict[int, str]:
|
|
183
|
+
"""Extract the per-device work-mode value-to-label mapping."""
|
|
184
|
+
for capability in capabilities or []:
|
|
185
|
+
if not isinstance(capability, dict):
|
|
186
|
+
continue
|
|
187
|
+
if capability.get("interfaceInfo") != INTERFACE_WORK_MODE:
|
|
188
|
+
continue
|
|
189
|
+
configuration = capability.get("configuration")
|
|
190
|
+
if isinstance(configuration, str):
|
|
191
|
+
try:
|
|
192
|
+
configuration = json.loads(configuration)
|
|
193
|
+
except (json.JSONDecodeError, TypeError):
|
|
194
|
+
configuration = None
|
|
195
|
+
if not isinstance(configuration, dict):
|
|
196
|
+
return {}
|
|
197
|
+
options: dict[int, str] = {}
|
|
198
|
+
for option in configuration.get("options") or []:
|
|
199
|
+
value = option.get("value")
|
|
200
|
+
label = option.get("label")
|
|
201
|
+
if value is not None and label:
|
|
202
|
+
options[value] = label
|
|
203
|
+
return options
|
|
204
|
+
return {}
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _parse_capabilities(
|
|
208
|
+
capabilities: list[dict[str, Any]] | None,
|
|
209
|
+
) -> dict[str, BeatbotCapability]:
|
|
210
|
+
"""Parse discovery capabilities into a mapping by interface key."""
|
|
211
|
+
parsed: dict[str, BeatbotCapability] = {}
|
|
212
|
+
for capability in capabilities or []:
|
|
213
|
+
if not isinstance(capability, dict):
|
|
214
|
+
continue
|
|
215
|
+
interface_info = capability.get("interfaceInfo")
|
|
216
|
+
if not interface_info:
|
|
217
|
+
continue
|
|
218
|
+
parsed[interface_info] = BeatbotCapability(
|
|
219
|
+
interface_info=interface_info,
|
|
220
|
+
retrievable=bool(capability.get("retrievable", False)),
|
|
221
|
+
proactively_reported=bool(capability.get("proactivelyReported", False)),
|
|
222
|
+
non_controllable=bool(capability.get("nonControllable", False)),
|
|
223
|
+
)
|
|
224
|
+
return parsed
|
|
225
|
+
|
|
226
|
+
async def get_device_states(self) -> dict[str, dict[str, Any]]:
|
|
227
|
+
"""Return batched runtime state for all devices."""
|
|
228
|
+
raw = await self._request("GET", DEVICE_STATES_PATH)
|
|
229
|
+
if isinstance(raw, str):
|
|
230
|
+
try:
|
|
231
|
+
payload = json.loads(raw)
|
|
232
|
+
except (json.JSONDecodeError, TypeError):
|
|
233
|
+
return {}
|
|
234
|
+
else:
|
|
235
|
+
payload = raw
|
|
236
|
+
devices = (payload or {}).get("devices") or []
|
|
237
|
+
return {
|
|
238
|
+
device["deviceId"]: {
|
|
239
|
+
"is_online": device.get("isOnline"),
|
|
240
|
+
"states": device.get("states") or {},
|
|
241
|
+
}
|
|
242
|
+
for device in devices
|
|
243
|
+
if device.get("deviceId")
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async def get_device_state(self, device_id: str) -> dict[str, Any]:
|
|
247
|
+
"""Return runtime state for one device."""
|
|
248
|
+
raw = await self._request("GET", f"{DEVICE_ACTIONS_PATH}/{device_id}/state")
|
|
249
|
+
if isinstance(raw, str):
|
|
250
|
+
try:
|
|
251
|
+
payload = json.loads(raw)
|
|
252
|
+
except (json.JSONDecodeError, TypeError):
|
|
253
|
+
return {}
|
|
254
|
+
else:
|
|
255
|
+
payload = raw
|
|
256
|
+
if not isinstance(payload, dict):
|
|
257
|
+
return {}
|
|
258
|
+
return {
|
|
259
|
+
"is_online": payload.get("isOnline"),
|
|
260
|
+
"states": payload.get("states") or {},
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async def send_action(self, device_id: str, interface_info: str) -> None:
|
|
264
|
+
"""Issue a parameterless action by its interface key."""
|
|
265
|
+
await self._request(
|
|
266
|
+
"POST",
|
|
267
|
+
f"{DEVICE_ACTIONS_PATH}/{device_id}/actions",
|
|
268
|
+
json_body={"interfaceInfo": interface_info},
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
async def set_work_mode(self, device_id: str, label: str) -> None:
|
|
272
|
+
"""Set a device's work mode by its advertised label."""
|
|
273
|
+
await self._request(
|
|
274
|
+
"POST",
|
|
275
|
+
f"{DEVICE_ACTIONS_PATH}/{device_id}/actions",
|
|
276
|
+
json_body={"interfaceInfo": INTERFACE_WORK_MODE, "label": label},
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
async def set_switch(self, device_id: str, interface_info: str, label: str) -> None:
|
|
280
|
+
"""Set an on/off capability."""
|
|
281
|
+
await self._request(
|
|
282
|
+
"POST",
|
|
283
|
+
f"{DEVICE_ACTIONS_PATH}/{device_id}/actions",
|
|
284
|
+
json_body={"interfaceInfo": interface_info, "label": label},
|
|
285
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Beatbot cloud protocol constants."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
HTTP_API_TIMEOUT: Final = 30
|
|
6
|
+
OAUTH2_AUTHORIZE_URL: Final = "https://oauth.beatbot.com/oauth2/authorize"
|
|
7
|
+
OAUTH2_TOKEN_URL: Final = "https://oauth.beatbot.com/oauth2/token"
|
|
8
|
+
OAUTH2_CLIENT_ID: Final = "home-assistant"
|
|
9
|
+
OAUTH2_SCOPE: Final = "device:info"
|
|
10
|
+
|
|
11
|
+
REGION_API_BASE_URL: Final = {
|
|
12
|
+
"cn": "https://cn-iot.beatbot.com",
|
|
13
|
+
"na": "https://na-iot.beatbot.com",
|
|
14
|
+
"eu": "https://eu-iot.beatbot.com",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
DEVICES_PATH: Final = "/openapi/v1/ha"
|
|
18
|
+
DEVICE_STATES_PATH: Final = "/openapi/v1/ha/state"
|
|
19
|
+
DEVICE_ACTIONS_PATH: Final = "/openapi/v1/ha"
|
|
20
|
+
EVENTS_PATH: Final = "/openapi/v1/ha/ws"
|
|
21
|
+
RESULT_SUCCESS_CODE: Final = 200
|
|
22
|
+
|
|
23
|
+
INTERFACE_WORK_MODE: Final = "select.work_mode"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Exceptions raised by the Beatbot cloud client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BeatbotError(Exception):
|
|
5
|
+
"""Base class for Beatbot client errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BeatbotAuthenticationError(BeatbotError):
|
|
9
|
+
"""The credentials are invalid and user authentication is required."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BeatbotConnectionError(BeatbotError):
|
|
13
|
+
"""The Beatbot cloud service could not be reached or returned bad data."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BeatbotTokenRejectedError(BeatbotAuthenticationError):
|
|
17
|
+
"""An access token was rejected and may be refreshed once."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, access_token: str, *, handshake: bool = False) -> None:
|
|
20
|
+
super().__init__("access token rejected")
|
|
21
|
+
self.access_token = access_token
|
|
22
|
+
self.handshake = handshake
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BeatbotConnectionReplacedError(BeatbotError):
|
|
26
|
+
"""The server replaced this event stream with a newer connection."""
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Low-level Beatbot cloud WebSocket event transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from aiohttp import (
|
|
9
|
+
ClientSession,
|
|
10
|
+
ClientWebSocketResponse,
|
|
11
|
+
WSMsgType,
|
|
12
|
+
WSServerHandshakeError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
BeatbotAuthenticationError,
|
|
17
|
+
BeatbotConnectionError,
|
|
18
|
+
BeatbotConnectionReplacedError,
|
|
19
|
+
BeatbotTokenRejectedError,
|
|
20
|
+
)
|
|
21
|
+
from .models import BeatbotEvent
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BeatbotEventStream:
|
|
25
|
+
"""Connect to and receive validated events from a Beatbot account stream."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
session: ClientSession,
|
|
30
|
+
url: str,
|
|
31
|
+
access_token: str,
|
|
32
|
+
*,
|
|
33
|
+
heartbeat: float = 30.0,
|
|
34
|
+
receive_timeout: float = 90.0,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Initialize an event stream without connecting it."""
|
|
37
|
+
self._session = session
|
|
38
|
+
self._url = url
|
|
39
|
+
self._access_token = access_token
|
|
40
|
+
self._heartbeat = heartbeat
|
|
41
|
+
self._receive_timeout = receive_timeout
|
|
42
|
+
self._ws: ClientWebSocketResponse | None = None
|
|
43
|
+
|
|
44
|
+
async def connect(self) -> None:
|
|
45
|
+
"""Open the WebSocket connection."""
|
|
46
|
+
try:
|
|
47
|
+
self._ws = await self._session.ws_connect(
|
|
48
|
+
self._url,
|
|
49
|
+
headers={"Authorization": f"Bearer {self._access_token}"},
|
|
50
|
+
heartbeat=self._heartbeat,
|
|
51
|
+
autoping=True,
|
|
52
|
+
)
|
|
53
|
+
except WSServerHandshakeError as err:
|
|
54
|
+
if err.status == 401:
|
|
55
|
+
raise BeatbotTokenRejectedError(
|
|
56
|
+
self._access_token, handshake=True
|
|
57
|
+
) from err
|
|
58
|
+
if err.status == 403:
|
|
59
|
+
raise BeatbotAuthenticationError from err
|
|
60
|
+
raise BeatbotConnectionError(str(err)) from err
|
|
61
|
+
|
|
62
|
+
async def receive(self) -> BeatbotEvent:
|
|
63
|
+
"""Receive and validate the next text event."""
|
|
64
|
+
if self._ws is None:
|
|
65
|
+
raise RuntimeError("Event stream is not connected")
|
|
66
|
+
message = await self._ws.receive(timeout=self._receive_timeout)
|
|
67
|
+
if message.type is WSMsgType.TEXT:
|
|
68
|
+
return self.parse_event(message.data)
|
|
69
|
+
if message.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.ERROR):
|
|
70
|
+
self._raise_for_close_code(self._ws.close_code, self._ws.exception())
|
|
71
|
+
raise BeatbotConnectionError(f"Unexpected WebSocket message: {message.type}")
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def parse_event(raw: str) -> BeatbotEvent:
|
|
75
|
+
"""Parse and validate a Beatbot event envelope."""
|
|
76
|
+
try:
|
|
77
|
+
event: Any = json.loads(raw)
|
|
78
|
+
except (json.JSONDecodeError, TypeError) as err:
|
|
79
|
+
raise BeatbotConnectionError("Event is not valid JSON") from err
|
|
80
|
+
if not isinstance(event, dict):
|
|
81
|
+
raise BeatbotConnectionError("Event is not an object")
|
|
82
|
+
event_id = event.get("eventId")
|
|
83
|
+
event_type = event.get("type")
|
|
84
|
+
device_id = event.get("deviceId")
|
|
85
|
+
if not all(
|
|
86
|
+
isinstance(value, str) and value
|
|
87
|
+
for value in (event_id, event_type, device_id)
|
|
88
|
+
):
|
|
89
|
+
raise BeatbotConnectionError("Event is missing eventId, type, or deviceId")
|
|
90
|
+
payload = event.get("payload")
|
|
91
|
+
if event_type == "device_removed":
|
|
92
|
+
if payload is not None:
|
|
93
|
+
raise BeatbotConnectionError("device_removed payload is not null")
|
|
94
|
+
elif not isinstance(payload, dict):
|
|
95
|
+
raise BeatbotConnectionError("Event payload is not an object")
|
|
96
|
+
return BeatbotEvent(event_id, event_type, device_id, payload)
|
|
97
|
+
|
|
98
|
+
def _raise_for_close_code(
|
|
99
|
+
self, code: int | None, error: BaseException | None
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Translate Beatbot close codes into public client exceptions."""
|
|
102
|
+
if code == 4001:
|
|
103
|
+
raise BeatbotTokenRejectedError(self._access_token) from error
|
|
104
|
+
if code == 4002:
|
|
105
|
+
raise BeatbotConnectionReplacedError from error
|
|
106
|
+
if code == 4003:
|
|
107
|
+
raise BeatbotAuthenticationError from error
|
|
108
|
+
raise BeatbotConnectionError(f"WebSocket closed with code {code}") from error
|
|
109
|
+
|
|
110
|
+
async def close(self) -> None:
|
|
111
|
+
"""Close the stream if connected."""
|
|
112
|
+
websocket, self._ws = self._ws, None
|
|
113
|
+
if websocket is not None and not websocket.closed:
|
|
114
|
+
await websocket.close()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: beatbot-cloud
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchronous Python client for the Beatbot cloud API
|
|
5
|
+
Author: Beatbot Robotics
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://www.beatbot.com
|
|
8
|
+
Project-URL: Repository, https://github.com/Beatbot-Robotics/beatbot-cloud-python
|
|
9
|
+
Project-URL: Issues, https://github.com/Beatbot-Robotics/beatbot-cloud-python/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: aiohttp>=3.11.0
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: coverage[toml]>=7.6; extra == "test"
|
|
23
|
+
Requires-Dist: pytest>=8.3; extra == "test"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "test"
|
|
25
|
+
Requires-Dist: ruff>=0.15; extra == "test"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# beatbot-cloud
|
|
29
|
+
|
|
30
|
+
`beatbot-cloud` is the asynchronous Python client for Beatbot cloud accounts.
|
|
31
|
+
It provides region-aware REST access, typed device models, and a WebSocket event
|
|
32
|
+
transport without depending on Home Assistant.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from beatbot_cloud import BeatbotClient
|
|
36
|
+
|
|
37
|
+
client = BeatbotClient(region="na", requester=oauth_request)
|
|
38
|
+
devices = await client.get_devices()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The caller owns authentication. `requester` is an async callable compatible
|
|
42
|
+
with `aiohttp.ClientSession.request`; it may add or refresh OAuth credentials
|
|
43
|
+
before forwarding the request.
|
|
44
|
+
|
|
45
|
+
## Development
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python -m pip install -e '.[test]'
|
|
49
|
+
pytest
|
|
50
|
+
ruff check .
|
|
51
|
+
ruff format --check .
|
|
52
|
+
python -m build
|
|
53
|
+
```
|
|
54
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/beatbot_cloud/__init__.py
|
|
5
|
+
src/beatbot_cloud/client.py
|
|
6
|
+
src/beatbot_cloud/const.py
|
|
7
|
+
src/beatbot_cloud/exceptions.py
|
|
8
|
+
src/beatbot_cloud/models.py
|
|
9
|
+
src/beatbot_cloud/py.typed
|
|
10
|
+
src/beatbot_cloud/websocket.py
|
|
11
|
+
src/beatbot_cloud.egg-info/PKG-INFO
|
|
12
|
+
src/beatbot_cloud.egg-info/SOURCES.txt
|
|
13
|
+
src/beatbot_cloud.egg-info/dependency_links.txt
|
|
14
|
+
src/beatbot_cloud.egg-info/requires.txt
|
|
15
|
+
src/beatbot_cloud.egg-info/top_level.txt
|
|
16
|
+
tests/test_client.py
|
|
17
|
+
tests/test_websocket.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
beatbot_cloud
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Tests for the Beatbot REST client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from aiohttp import ClientResponseError
|
|
10
|
+
|
|
11
|
+
from beatbot_cloud import (
|
|
12
|
+
BeatbotAuthenticationError,
|
|
13
|
+
BeatbotClient,
|
|
14
|
+
BeatbotConnectionError,
|
|
15
|
+
)
|
|
16
|
+
from beatbot_cloud.const import OAUTH2_TOKEN_URL, REGION_API_BASE_URL
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Response:
|
|
20
|
+
"""Small response double."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, data, *, status=200, content_type="application/json"):
|
|
23
|
+
self.status = status
|
|
24
|
+
self.headers = {"Content-Type": content_type}
|
|
25
|
+
self.body = data if isinstance(data, str) else json.dumps(data)
|
|
26
|
+
|
|
27
|
+
async def text(self):
|
|
28
|
+
return self.body
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Requester:
|
|
32
|
+
"""Record requests and return or raise a configured result."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, result):
|
|
35
|
+
self.result = result
|
|
36
|
+
self.calls = []
|
|
37
|
+
|
|
38
|
+
async def __call__(self, method, url, **kwargs):
|
|
39
|
+
self.calls.append((method, url, kwargs))
|
|
40
|
+
if isinstance(self.result, Exception):
|
|
41
|
+
raise self.result
|
|
42
|
+
return self.result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def client(result, region="na"):
|
|
46
|
+
requester = Requester(result)
|
|
47
|
+
return BeatbotClient(region, requester), requester
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def envelope(data=None, *, code=200, message=None):
|
|
51
|
+
return Response({"code": code, "message": message, "data": data})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize("region", ["cn", "na", "eu"])
|
|
55
|
+
def test_region_and_event_url(region):
|
|
56
|
+
api, _ = client(envelope(), region)
|
|
57
|
+
assert api._base_url == REGION_API_BASE_URL[region]
|
|
58
|
+
assert api.event_stream_url.startswith("wss://")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_unknown_region():
|
|
62
|
+
with pytest.raises(ValueError, match="Unknown or missing"):
|
|
63
|
+
client(envelope(), "moon")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_event_url_preserves_non_https_scheme():
|
|
67
|
+
api, _ = client(envelope())
|
|
68
|
+
api._base_url = "ws://example.test"
|
|
69
|
+
assert api.event_stream_url == "ws://example.test/openapi/v1/ha/ws"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize("status", [401, 403])
|
|
73
|
+
async def test_request_rejects_auth_status(status):
|
|
74
|
+
api, _ = client(Response("denied", status=status))
|
|
75
|
+
with pytest.raises(BeatbotAuthenticationError):
|
|
76
|
+
await api._request("GET", "/test")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def test_request_rejects_http_error():
|
|
80
|
+
api, _ = client(Response("failed", status=500))
|
|
81
|
+
with pytest.raises(BeatbotConnectionError, match="500"):
|
|
82
|
+
await api._request("GET", "/test")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize(
|
|
86
|
+
("response", "message"),
|
|
87
|
+
[
|
|
88
|
+
(Response("not json", content_type="text/plain"), "non-JSON"),
|
|
89
|
+
(Response("[]"), "invalid response envelope"),
|
|
90
|
+
(envelope(code=400, message="bad"), "API error 400: bad"),
|
|
91
|
+
],
|
|
92
|
+
)
|
|
93
|
+
async def test_request_rejects_invalid_envelope(response, message):
|
|
94
|
+
api, _ = client(response)
|
|
95
|
+
with pytest.raises(BeatbotConnectionError, match=message):
|
|
96
|
+
await api._request("GET", "/test")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def test_request_forwards_options_and_returns_data():
|
|
100
|
+
api, requester = client(envelope({"ok": True}))
|
|
101
|
+
result = await api._request(
|
|
102
|
+
"POST", "/test", params={"a": "b"}, json_body={"value": 1}
|
|
103
|
+
)
|
|
104
|
+
assert result == {"ok": True}
|
|
105
|
+
method, url, kwargs = requester.calls[0]
|
|
106
|
+
assert method == "POST"
|
|
107
|
+
assert url.endswith("/test")
|
|
108
|
+
assert kwargs["params"] == {"a": "b"}
|
|
109
|
+
assert kwargs["json"] == {"value": 1}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.parametrize("status", [400, 401, 403, 499])
|
|
113
|
+
async def test_terminal_oauth_error_requires_authentication(status):
|
|
114
|
+
error = ClientResponseError(
|
|
115
|
+
SimpleNamespace(real_url=OAUTH2_TOKEN_URL), (), status=status
|
|
116
|
+
)
|
|
117
|
+
api, _ = client(error)
|
|
118
|
+
with pytest.raises(BeatbotAuthenticationError):
|
|
119
|
+
await api._request("GET", "/test")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.parametrize("status", [408, 429, 500])
|
|
123
|
+
async def test_transient_oauth_error_is_connection_error(status):
|
|
124
|
+
error = ClientResponseError(
|
|
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")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def test_non_oauth_exception_is_connection_error():
|
|
133
|
+
api, _ = client(RuntimeError("offline"))
|
|
134
|
+
with pytest.raises(BeatbotConnectionError, match="offline"):
|
|
135
|
+
await api._request("GET", "/test")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def test_get_devices_empty():
|
|
139
|
+
api, _ = client(envelope(None))
|
|
140
|
+
assert await api.get_devices() == []
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def test_get_devices_accepts_object_payload():
|
|
144
|
+
api, _ = client(envelope({"devices": []}))
|
|
145
|
+
assert await api.get_devices() == []
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def test_get_devices_rejects_invalid_string():
|
|
149
|
+
api, _ = client(envelope("not json"))
|
|
150
|
+
with pytest.raises(BeatbotConnectionError, match="Invalid discovery"):
|
|
151
|
+
await api.get_devices()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def test_get_devices_parses_models_and_capabilities():
|
|
155
|
+
configuration = json.dumps(
|
|
156
|
+
{"options": [{"value": 0, "label": "quick"}, {"value": None}]}
|
|
157
|
+
)
|
|
158
|
+
data = {
|
|
159
|
+
"devices": [
|
|
160
|
+
{},
|
|
161
|
+
{
|
|
162
|
+
"deviceId": "device-1",
|
|
163
|
+
"productId": "product-1",
|
|
164
|
+
"productCategory": "pool_clean_bot",
|
|
165
|
+
"name": "Pool bot",
|
|
166
|
+
"model": "Aqua",
|
|
167
|
+
"isOnline": True,
|
|
168
|
+
"versions": [None, {"channel": 1, "version": "2.0"}],
|
|
169
|
+
"capabilities": [
|
|
170
|
+
None,
|
|
171
|
+
{},
|
|
172
|
+
{
|
|
173
|
+
"interfaceInfo": "select.work_mode",
|
|
174
|
+
"configuration": configuration,
|
|
175
|
+
"retrievable": True,
|
|
176
|
+
"proactivelyReported": True,
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
api, _ = client(envelope(json.dumps(data)))
|
|
183
|
+
devices = await api.get_devices()
|
|
184
|
+
assert len(devices) == 1
|
|
185
|
+
assert devices[0].device_id == "device-1"
|
|
186
|
+
assert devices[0].work_mode_options == {0: "quick"}
|
|
187
|
+
assert devices[0].versions[0].version == "2.0"
|
|
188
|
+
assert devices[0].capabilities["select.work_mode"].retrievable
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@pytest.mark.parametrize("configuration", ["bad json", [], None])
|
|
192
|
+
def test_invalid_work_mode_configuration(configuration):
|
|
193
|
+
assert (
|
|
194
|
+
BeatbotClient._parse_work_mode_options(
|
|
195
|
+
[{"interfaceInfo": "select.work_mode", "configuration": configuration}]
|
|
196
|
+
)
|
|
197
|
+
== {}
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def test_get_device_states():
|
|
202
|
+
api, _ = client(
|
|
203
|
+
envelope(
|
|
204
|
+
{
|
|
205
|
+
"devices": [
|
|
206
|
+
{},
|
|
207
|
+
{"deviceId": "d1", "isOnline": True, "states": {"x": 1}},
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
assert await api.get_device_states() == {
|
|
213
|
+
"d1": {"is_online": True, "states": {"x": 1}}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def test_get_device_states_invalid_string():
|
|
218
|
+
api, _ = client(envelope("bad"))
|
|
219
|
+
assert await api.get_device_states() == {}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.parametrize("data", ["bad", [], None])
|
|
223
|
+
async def test_get_device_state_invalid(data):
|
|
224
|
+
api, _ = client(envelope(data))
|
|
225
|
+
assert await api.get_device_state("d1") == {}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def test_get_device_state():
|
|
229
|
+
api, _ = client(envelope({"isOnline": False, "states": {"battery": 10}}))
|
|
230
|
+
assert await api.get_device_state("d1") == {
|
|
231
|
+
"is_online": False,
|
|
232
|
+
"states": {"battery": 10},
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def test_actions():
|
|
237
|
+
api, requester = client(envelope())
|
|
238
|
+
await api.send_action("d1", "vacuum.start")
|
|
239
|
+
await api.set_work_mode("d1", "quick")
|
|
240
|
+
await api.set_switch("d1", "switch.child_lock", "on")
|
|
241
|
+
assert [call[2]["json"] for call in requester.calls] == [
|
|
242
|
+
{"interfaceInfo": "vacuum.start"},
|
|
243
|
+
{"interfaceInfo": "select.work_mode", "label": "quick"},
|
|
244
|
+
{"interfaceInfo": "switch.child_lock", "label": "on"},
|
|
245
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Tests for the Beatbot WebSocket transport."""
|
|
2
|
+
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from unittest.mock import AsyncMock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from aiohttp import WSMsgType, WSServerHandshakeError
|
|
8
|
+
|
|
9
|
+
from beatbot_cloud import (
|
|
10
|
+
BeatbotAuthenticationError,
|
|
11
|
+
BeatbotConnectionError,
|
|
12
|
+
BeatbotConnectionReplacedError,
|
|
13
|
+
BeatbotEventStream,
|
|
14
|
+
BeatbotTokenRejectedError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def stream(session=None):
|
|
19
|
+
return BeatbotEventStream(session or SimpleNamespace(), "wss://test", "token")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.parametrize(
|
|
23
|
+
"raw",
|
|
24
|
+
["not-json", "[]", "{}", '{"eventId":"1","type":"status","deviceId":"d"}'],
|
|
25
|
+
)
|
|
26
|
+
def test_parse_rejects_invalid_events(raw):
|
|
27
|
+
with pytest.raises(BeatbotConnectionError):
|
|
28
|
+
BeatbotEventStream.parse_event(raw)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_parse_rejects_removed_payload():
|
|
32
|
+
with pytest.raises(BeatbotConnectionError, match="not null"):
|
|
33
|
+
BeatbotEventStream.parse_event(
|
|
34
|
+
'{"eventId":"1","type":"device_removed","deviceId":"d","payload":{}}'
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_parse_event():
|
|
39
|
+
event = BeatbotEventStream.parse_event(
|
|
40
|
+
'{"eventId":"1","type":"status","deviceId":"d","payload":{"online":true}}'
|
|
41
|
+
)
|
|
42
|
+
assert event.event_id == "1"
|
|
43
|
+
assert event.payload == {"online": True}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_parse_removed_event():
|
|
47
|
+
event = BeatbotEventStream.parse_event(
|
|
48
|
+
'{"eventId":"1","type":"device_removed","deviceId":"d","payload":null}'
|
|
49
|
+
)
|
|
50
|
+
assert event.payload is None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def test_connect_and_close():
|
|
54
|
+
websocket = SimpleNamespace(closed=False, close=AsyncMock())
|
|
55
|
+
session = SimpleNamespace(ws_connect=AsyncMock(return_value=websocket))
|
|
56
|
+
event_stream = stream(session)
|
|
57
|
+
await event_stream.connect()
|
|
58
|
+
await event_stream.close()
|
|
59
|
+
websocket.close.assert_awaited_once()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.mark.parametrize(
|
|
63
|
+
("status", "error_type"),
|
|
64
|
+
[
|
|
65
|
+
(401, BeatbotTokenRejectedError),
|
|
66
|
+
(403, BeatbotAuthenticationError),
|
|
67
|
+
(500, BeatbotConnectionError),
|
|
68
|
+
],
|
|
69
|
+
)
|
|
70
|
+
async def test_connect_translates_handshake_errors(status, error_type):
|
|
71
|
+
error = WSServerHandshakeError(
|
|
72
|
+
SimpleNamespace(real_url="wss://test"), (), status=status
|
|
73
|
+
)
|
|
74
|
+
session = SimpleNamespace(ws_connect=AsyncMock(side_effect=error))
|
|
75
|
+
with pytest.raises(error_type):
|
|
76
|
+
await stream(session).connect()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def test_receive_requires_connection():
|
|
80
|
+
with pytest.raises(RuntimeError, match="not connected"):
|
|
81
|
+
await stream().receive()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def test_receive_text_event():
|
|
85
|
+
message = SimpleNamespace(
|
|
86
|
+
type=WSMsgType.TEXT,
|
|
87
|
+
data='{"eventId":"1","type":"status","deviceId":"d","payload":{"online":true}}',
|
|
88
|
+
)
|
|
89
|
+
event_stream = stream()
|
|
90
|
+
event_stream._ws = SimpleNamespace(receive=AsyncMock(return_value=message))
|
|
91
|
+
assert (await event_stream.receive()).device_id == "d"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize(
|
|
95
|
+
("code", "error_type"),
|
|
96
|
+
[
|
|
97
|
+
(4001, BeatbotTokenRejectedError),
|
|
98
|
+
(4002, BeatbotConnectionReplacedError),
|
|
99
|
+
(4003, BeatbotAuthenticationError),
|
|
100
|
+
(4008, BeatbotConnectionError),
|
|
101
|
+
],
|
|
102
|
+
)
|
|
103
|
+
async def test_receive_translates_close_codes(code, error_type):
|
|
104
|
+
message = SimpleNamespace(type=WSMsgType.CLOSE, data=None)
|
|
105
|
+
event_stream = stream()
|
|
106
|
+
event_stream._ws = SimpleNamespace(
|
|
107
|
+
receive=AsyncMock(return_value=message),
|
|
108
|
+
close_code=code,
|
|
109
|
+
exception=lambda: None,
|
|
110
|
+
)
|
|
111
|
+
with pytest.raises(error_type):
|
|
112
|
+
await event_stream.receive()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def test_receive_rejects_unexpected_message():
|
|
116
|
+
message = SimpleNamespace(type=WSMsgType.BINARY, data=b"data")
|
|
117
|
+
event_stream = stream()
|
|
118
|
+
event_stream._ws = SimpleNamespace(receive=AsyncMock(return_value=message))
|
|
119
|
+
with pytest.raises(BeatbotConnectionError, match="Unexpected"):
|
|
120
|
+
await event_stream.receive()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def test_close_without_connection():
|
|
124
|
+
await stream().close()
|