beatbot-cloud 0.4.0__tar.gz → 0.4.2__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 (25) hide show
  1. {beatbot_cloud-0.4.0/src/beatbot_cloud.egg-info → beatbot_cloud-0.4.2}/PKG-INFO +9 -3
  2. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/README.md +8 -2
  3. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/pyproject.toml +1 -1
  4. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/src/beatbot_cloud/client.py +1 -1
  5. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/src/beatbot_cloud/event_client.py +33 -4
  6. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2/src/beatbot_cloud.egg-info}/PKG-INFO +9 -3
  7. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/tests/test_client.py +6 -0
  8. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/tests/test_event_client.py +70 -0
  9. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/LICENSE +0 -0
  10. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/setup.cfg +0 -0
  11. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/src/beatbot_cloud/__init__.py +0 -0
  12. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/src/beatbot_cloud/auth.py +0 -0
  13. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/src/beatbot_cloud/const.py +0 -0
  14. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/src/beatbot_cloud/exceptions.py +0 -0
  15. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/src/beatbot_cloud/models.py +0 -0
  16. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/src/beatbot_cloud/protocol.py +0 -0
  17. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/src/beatbot_cloud/py.typed +0 -0
  18. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/src/beatbot_cloud/websocket.py +0 -0
  19. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/src/beatbot_cloud.egg-info/SOURCES.txt +0 -0
  20. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/src/beatbot_cloud.egg-info/dependency_links.txt +0 -0
  21. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/src/beatbot_cloud.egg-info/requires.txt +0 -0
  22. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/src/beatbot_cloud.egg-info/top_level.txt +0 -0
  23. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/tests/test_auth.py +0 -0
  24. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/tests/test_protocol.py +0 -0
  25. {beatbot_cloud-0.4.0 → beatbot_cloud-0.4.2}/tests/test_websocket.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: beatbot-cloud
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Asynchronous Python client for the Beatbot cloud API
5
5
  Author: Beatbot Robotics
6
6
  License-Expression: Apache-2.0
@@ -46,7 +46,9 @@ events = BeatbotEventClient(
46
46
  session,
47
47
  client.event_stream_url,
48
48
  client.async_get_access_token,
49
- async_handle_event,
49
+ state_callback=async_handle_state,
50
+ device_added_callback=async_handle_device_added,
51
+ device_removed_callback=async_handle_device_removed,
50
52
  reconnect_callback=async_handle_reconnect,
51
53
  token_refresh_callback=async_refresh_access_token,
52
54
  )
@@ -56,7 +58,11 @@ await events.async_run()
56
58
  The caller provides an access token or a synchronous/asynchronous token provider.
57
59
  The library owns REST request construction and WebSocket reconnection; applications
58
60
  can register callbacks for events, successful reconnections, and rejected-token
59
- refreshes.
61
+ refreshes. State callbacks receive a `BeatbotEvent`, which can be applied with
62
+ `event.apply_to(device)`. Device-added and device-removed callbacks receive the
63
+ device ID. The library routes cloud event types and ignores unknown types for
64
+ these specialized callbacks. All callbacks may be synchronous or asynchronous.
65
+ The optional positional `event_callback` remains supported and receives all events.
60
66
 
61
67
  ## Development
62
68
 
@@ -18,7 +18,9 @@ events = BeatbotEventClient(
18
18
  session,
19
19
  client.event_stream_url,
20
20
  client.async_get_access_token,
21
- async_handle_event,
21
+ state_callback=async_handle_state,
22
+ device_added_callback=async_handle_device_added,
23
+ device_removed_callback=async_handle_device_removed,
22
24
  reconnect_callback=async_handle_reconnect,
23
25
  token_refresh_callback=async_refresh_access_token,
24
26
  )
@@ -28,7 +30,11 @@ await events.async_run()
28
30
  The caller provides an access token or a synchronous/asynchronous token provider.
29
31
  The library owns REST request construction and WebSocket reconnection; applications
30
32
  can register callbacks for events, successful reconnections, and rejected-token
31
- refreshes.
33
+ refreshes. State callbacks receive a `BeatbotEvent`, which can be applied with
34
+ `event.apply_to(device)`. Device-added and device-removed callbacks receive the
35
+ device ID. The library routes cloud event types and ignores unknown types for
36
+ these specialized callbacks. All callbacks may be synchronous or asynchronous.
37
+ The optional positional `event_callback` remains supported and receives all events.
32
38
 
33
39
  ## Development
34
40
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "beatbot-cloud"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Asynchronous Python client for the Beatbot cloud API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -89,7 +89,7 @@ class BeatbotClient:
89
89
  },
90
90
  timeout=ClientTimeout(total=HTTP_API_TIMEOUT),
91
91
  )
92
- except ClientError as err:
92
+ except (TimeoutError, ClientError) as err:
93
93
  raise BeatbotConnectionError(str(err)) from err
94
94
 
95
95
  body = await response.text()
@@ -29,6 +29,7 @@ _RECONNECT_DELAYS = (1.0, 2.0, 4.0, 8.0, 30.0, 60.0)
29
29
  _RECONNECT_JITTER = 0.2
30
30
 
31
31
  EventCallback = Callable[[BeatbotEvent], None | Awaitable[None]]
32
+ DeviceCallback = Callable[[str], None | Awaitable[None]]
32
33
  ReconnectCallback = Callable[[], None | Awaitable[None]]
33
34
  TokenRefreshCallback = Callable[[str], str | Awaitable[str]]
34
35
 
@@ -41,8 +42,11 @@ class BeatbotEventClient:
41
42
  session: ClientSession,
42
43
  url: str,
43
44
  access_token: AccessTokenProvider,
44
- event_callback: EventCallback,
45
+ event_callback: EventCallback | None = None,
45
46
  *,
47
+ state_callback: EventCallback | None = None,
48
+ device_added_callback: DeviceCallback | None = None,
49
+ device_removed_callback: DeviceCallback | None = None,
46
50
  reconnect_callback: ReconnectCallback | None = None,
47
51
  token_refresh_callback: TokenRefreshCallback | None = None,
48
52
  ) -> None:
@@ -51,6 +55,9 @@ class BeatbotEventClient:
51
55
  self._url = url
52
56
  self._access_token = access_token
53
57
  self._event_callback = event_callback
58
+ self._state_callback = state_callback
59
+ self._device_added_callback = device_added_callback
60
+ self._device_removed_callback = device_removed_callback
54
61
  self._reconnect_callback = reconnect_callback
55
62
  self._token_refresh_callback = token_refresh_callback
56
63
  self._stream: BeatbotEventStream | None = None
@@ -160,14 +167,36 @@ class BeatbotEventClient:
160
167
  if event.event_id in self._seen_event_ids:
161
168
  continue
162
169
  self._remember_event(event.event_id)
163
- result = self._event_callback(event)
164
- if isawaitable(result):
165
- await result
170
+ await self._async_dispatch_event(event)
166
171
  finally:
167
172
  await stream.close()
168
173
  if self._stream is stream:
169
174
  self._stream = None
170
175
 
176
+ async def _async_dispatch_event(self, event: BeatbotEvent) -> None:
177
+ """Route cloud event types to consumer callbacks."""
178
+ if self._event_callback is not None:
179
+ result = self._event_callback(event)
180
+ if isawaitable(result):
181
+ await result
182
+ if event.event_type in ("properties_changed", "status"):
183
+ if self._state_callback is not None:
184
+ result = self._state_callback(event)
185
+ if isawaitable(result):
186
+ await result
187
+ elif event.event_type == "device_added":
188
+ if self._device_added_callback is not None:
189
+ result = self._device_added_callback(event.device_id)
190
+ if isawaitable(result):
191
+ await result
192
+ elif (
193
+ event.event_type == "device_removed"
194
+ and self._device_removed_callback is not None
195
+ ):
196
+ result = self._device_removed_callback(event.device_id)
197
+ if isawaitable(result):
198
+ await result
199
+
171
200
  async def async_close(self) -> None:
172
201
  """Stop receiving and close the current connection."""
173
202
  self._stopping = True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: beatbot-cloud
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Asynchronous Python client for the Beatbot cloud API
5
5
  Author: Beatbot Robotics
6
6
  License-Expression: Apache-2.0
@@ -46,7 +46,9 @@ events = BeatbotEventClient(
46
46
  session,
47
47
  client.event_stream_url,
48
48
  client.async_get_access_token,
49
- async_handle_event,
49
+ state_callback=async_handle_state,
50
+ device_added_callback=async_handle_device_added,
51
+ device_removed_callback=async_handle_device_removed,
50
52
  reconnect_callback=async_handle_reconnect,
51
53
  token_refresh_callback=async_refresh_access_token,
52
54
  )
@@ -56,7 +58,11 @@ await events.async_run()
56
58
  The caller provides an access token or a synchronous/asynchronous token provider.
57
59
  The library owns REST request construction and WebSocket reconnection; applications
58
60
  can register callbacks for events, successful reconnections, and rejected-token
59
- refreshes.
61
+ refreshes. State callbacks receive a `BeatbotEvent`, which can be applied with
62
+ `event.apply_to(device)`. Device-added and device-removed callbacks receive the
63
+ device ID. The library routes cloud event types and ignores unknown types for
64
+ these specialized callbacks. All callbacks may be synchronous or asynchronous.
65
+ The optional positional `event_callback` remains supported and receives all events.
60
66
 
61
67
  ## Development
62
68
 
@@ -116,6 +116,12 @@ async def test_client_error_is_connection_error():
116
116
  await api._request("GET", "/test")
117
117
 
118
118
 
119
+ async def test_timeout_is_connection_error():
120
+ api, _ = client(TimeoutError("timed out"))
121
+ with pytest.raises(BeatbotConnectionError, match="timed out"):
122
+ await api._request("GET", "/test")
123
+
124
+
119
125
  async def test_async_access_token_provider():
120
126
  async def access_token():
121
127
  return "rotated-token"
@@ -49,6 +49,76 @@ async def test_connect_delivers_unique_events_and_ignores_malformed():
49
49
  stream.close.assert_awaited_once()
50
50
 
51
51
 
52
+ @pytest.mark.parametrize("callback_type", [Mock, AsyncMock])
53
+ @pytest.mark.parametrize(
54
+ ("event_type", "callback_name", "payload"),
55
+ [
56
+ (
57
+ "properties_changed",
58
+ "state_callback",
59
+ {"interfaceInfo": "vacuum.battery", "value": 42},
60
+ ),
61
+ ("status", "state_callback", {"online": False}),
62
+ ("device_added", "device_added_callback", {"deviceId": "device-1"}),
63
+ ("device_removed", "device_removed_callback", None),
64
+ ],
65
+ )
66
+ async def test_dispatches_specialized_callbacks(
67
+ callback_type, event_type, callback_name, payload
68
+ ):
69
+ callback = callback_type()
70
+ event = BeatbotEvent("event-1", event_type, "device-1", payload)
71
+ event_client = client(event_callback=None, **{callback_name: callback})
72
+ stream = SimpleNamespace(
73
+ connect=AsyncMock(),
74
+ receive=AsyncMock(side_effect=[event, event, asyncio.CancelledError]),
75
+ close=AsyncMock(),
76
+ )
77
+
78
+ with (
79
+ patch("beatbot_cloud.event_client.BeatbotEventStream", return_value=stream),
80
+ pytest.raises(asyncio.CancelledError),
81
+ ):
82
+ await event_client._async_connect_and_receive()
83
+
84
+ argument = event if callback_name == "state_callback" else "device-1"
85
+ callback.assert_called_once_with(argument)
86
+ if callback_type is AsyncMock:
87
+ callback.assert_awaited_once_with(argument)
88
+
89
+
90
+ @pytest.mark.parametrize(
91
+ "event_type",
92
+ ["status", "properties_changed", "device_added", "device_removed", "future_type"],
93
+ )
94
+ async def test_dispatch_without_callbacks(event_type):
95
+ event_client = client(event_callback=None)
96
+ await event_client._async_dispatch_event(
97
+ BeatbotEvent("event-1", event_type, "device-1", {})
98
+ )
99
+
100
+
101
+ async def test_unknown_event_only_reaches_legacy_callback():
102
+ legacy = Mock()
103
+ state = Mock()
104
+ added = Mock()
105
+ removed = Mock()
106
+ event_client = client(
107
+ event_callback=legacy,
108
+ state_callback=state,
109
+ device_added_callback=added,
110
+ device_removed_callback=removed,
111
+ )
112
+ event = BeatbotEvent("event-1", "future_type", "device-1", {})
113
+
114
+ await event_client._async_dispatch_event(event)
115
+
116
+ legacy.assert_called_once_with(event)
117
+ state.assert_not_called()
118
+ added.assert_not_called()
119
+ removed.assert_not_called()
120
+
121
+
52
122
  async def test_reconnect_callback_runs_after_first_connection():
53
123
  reconnect_callback = AsyncMock()
54
124
  event_client = client(reconnect_callback=reconnect_callback)
File without changes
File without changes