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.
@@ -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
+ )
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."""
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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