beatbot-cloud 0.1.0__py3-none-any.whl
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/__init__.py +24 -0
- beatbot_cloud/client.py +285 -0
- beatbot_cloud/const.py +23 -0
- beatbot_cloud/exceptions.py +26 -0
- beatbot_cloud/models.py +55 -0
- beatbot_cloud/py.typed +1 -0
- beatbot_cloud/websocket.py +114 -0
- beatbot_cloud-0.1.0.dist-info/METADATA +54 -0
- beatbot_cloud-0.1.0.dist-info/RECORD +12 -0
- beatbot_cloud-0.1.0.dist-info/WHEEL +5 -0
- beatbot_cloud-0.1.0.dist-info/licenses/LICENSE +18 -0
- beatbot_cloud-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
beatbot_cloud/client.py
ADDED
|
@@ -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
|
+
)
|
beatbot_cloud/const.py
ADDED
|
@@ -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."""
|
beatbot_cloud/models.py
ADDED
|
@@ -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
|
beatbot_cloud/py.typed
ADDED
|
@@ -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,12 @@
|
|
|
1
|
+
beatbot_cloud/__init__.py,sha256=cXcWVEDI_EU2UdjDlJn5SnunpQM4fEoAqr9l6LWl9Kw,663
|
|
2
|
+
beatbot_cloud/client.py,sha256=yUJtCHMeR5vfzUzdV2XpWYXcStslaRe2kDnWX0mIA00,10655
|
|
3
|
+
beatbot_cloud/const.py,sha256=qtjuxVPUW1JtiZZaQHTYR1bBbUOJsFxQ0Tbm0mkomcI,731
|
|
4
|
+
beatbot_cloud/exceptions.py,sha256=zlmxrdV-SuVQ35c7ZlaBtplNojsdzXFuc5NLxjnh9gU,846
|
|
5
|
+
beatbot_cloud/models.py,sha256=GLgftkkVqbXN8a3BYil4GCZaKUYgZ_F_tYF41ljfdgw,1247
|
|
6
|
+
beatbot_cloud/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
7
|
+
beatbot_cloud/websocket.py,sha256=ukrdiVvGhYLR8uSLLIpFqWuayAwbExc__XEMHgOMJIY,4275
|
|
8
|
+
beatbot_cloud-0.1.0.dist-info/licenses/LICENSE,sha256=yMukxtKwZ3JaL4OSNUSa_KSSGoXmIJtTJV_8Nkh8Gpo,746
|
|
9
|
+
beatbot_cloud-0.1.0.dist-info/METADATA,sha256=NHtvdo0YUXdq62hJARsKbj7mtR0VGq6a4-onk570Qy4,1734
|
|
10
|
+
beatbot_cloud-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
beatbot_cloud-0.1.0.dist-info/top_level.txt,sha256=rBtWms-oy74qMMPYS21LqxPcKCMRf6TiliXmXfzbTNw,14
|
|
12
|
+
beatbot_cloud-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
beatbot_cloud
|