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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,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,7 @@
1
+ aiohttp>=3.11.0
2
+
3
+ [test]
4
+ coverage[toml]>=7.6
5
+ pytest>=8.3
6
+ pytest-asyncio>=0.24
7
+ ruff>=0.15
@@ -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()