beatbot-cloud 0.4.1__tar.gz → 0.4.3__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.
- {beatbot_cloud-0.4.1/src/beatbot_cloud.egg-info → beatbot_cloud-0.4.3}/PKG-INFO +9 -3
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/README.md +8 -2
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/pyproject.toml +1 -1
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/src/beatbot_cloud/client.py +5 -1
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/src/beatbot_cloud/event_client.py +33 -4
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3/src/beatbot_cloud.egg-info}/PKG-INFO +9 -3
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/tests/test_client.py +22 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/tests/test_event_client.py +70 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/LICENSE +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/setup.cfg +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/src/beatbot_cloud/__init__.py +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/src/beatbot_cloud/auth.py +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/src/beatbot_cloud/const.py +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/src/beatbot_cloud/exceptions.py +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/src/beatbot_cloud/models.py +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/src/beatbot_cloud/protocol.py +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/src/beatbot_cloud/py.typed +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/src/beatbot_cloud/websocket.py +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/src/beatbot_cloud.egg-info/SOURCES.txt +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/src/beatbot_cloud.egg-info/dependency_links.txt +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/src/beatbot_cloud.egg-info/requires.txt +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/src/beatbot_cloud.egg-info/top_level.txt +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/tests/test_auth.py +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/tests/test_protocol.py +0 -0
- {beatbot_cloud-0.4.1 → beatbot_cloud-0.4.3}/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.
|
|
3
|
+
Version: 0.4.3
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -92,7 +92,11 @@ class BeatbotClient:
|
|
|
92
92
|
except (TimeoutError, ClientError) as err:
|
|
93
93
|
raise BeatbotConnectionError(str(err)) from err
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
try:
|
|
96
|
+
body = await response.text()
|
|
97
|
+
except (TimeoutError, ClientError) as err:
|
|
98
|
+
raise BeatbotConnectionError(str(err)) from err
|
|
99
|
+
|
|
96
100
|
if response.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
|
|
97
101
|
raise BeatbotAuthenticationError(f"Unauthorized: {response.status}")
|
|
98
102
|
if response.status >= HTTPStatus.BAD_REQUEST:
|
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
Version: 0.4.3
|
|
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
|
-
|
|
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
|
|
|
@@ -122,6 +122,28 @@ async def test_timeout_is_connection_error():
|
|
|
122
122
|
await api._request("GET", "/test")
|
|
123
123
|
|
|
124
124
|
|
|
125
|
+
@pytest.mark.parametrize(
|
|
126
|
+
("error", "message"),
|
|
127
|
+
[
|
|
128
|
+
(ClientError("body read failed"), "body read failed"),
|
|
129
|
+
(TimeoutError("body read timed out"), "body read timed out"),
|
|
130
|
+
],
|
|
131
|
+
ids=["client-error", "timeout"],
|
|
132
|
+
)
|
|
133
|
+
async def test_body_read_error_is_connection_error(error, message):
|
|
134
|
+
"""Reading the response body cannot escape as a raw aiohttp error."""
|
|
135
|
+
response = Response("{}")
|
|
136
|
+
|
|
137
|
+
async def failing_text():
|
|
138
|
+
raise error
|
|
139
|
+
|
|
140
|
+
response.text = failing_text
|
|
141
|
+
api, _ = client(response)
|
|
142
|
+
|
|
143
|
+
with pytest.raises(BeatbotConnectionError, match=message):
|
|
144
|
+
await api._request("GET", "/test")
|
|
145
|
+
|
|
146
|
+
|
|
125
147
|
async def test_async_access_token_provider():
|
|
126
148
|
async def access_token():
|
|
127
149
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|