beatbot-cloud 0.2.0__tar.gz → 0.4.0__tar.gz

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