botslab360 0.4.2__tar.gz → 0.6.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.
- {botslab360-0.4.2/src/botslab360.egg-info → botslab360-0.6.0}/PKG-INFO +55 -11
- {botslab360-0.4.2 → botslab360-0.6.0}/README.md +54 -10
- {botslab360-0.4.2 → botslab360-0.6.0}/pyproject.toml +1 -1
- {botslab360-0.4.2 → botslab360-0.6.0}/src/botslab360/__init__.py +2 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/src/botslab360/client.py +66 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/src/botslab360/commands.py +8 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/src/botslab360/models.py +27 -3
- {botslab360-0.4.2 → botslab360-0.6.0}/src/botslab360/protocol.py +121 -1
- {botslab360-0.4.2 → botslab360-0.6.0}/src/botslab360/rooms.py +5 -7
- {botslab360-0.4.2 → botslab360-0.6.0/src/botslab360.egg-info}/PKG-INFO +55 -11
- {botslab360-0.4.2 → botslab360-0.6.0}/src/botslab360.egg-info/SOURCES.txt +2 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/tests/test_commands.py +43 -0
- botslab360-0.6.0/tests/test_network_info.py +212 -0
- botslab360-0.6.0/tests/test_network_info_diagnostic.py +131 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/tests/test_protocol.py +20 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/tests/test_public_room_api_diagnostic.py +77 -1
- {botslab360-0.4.2 → botslab360-0.6.0}/tests/test_rooms.py +39 -16
- {botslab360-0.4.2 → botslab360-0.6.0}/ATTRIBUTION.md +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/LICENSE +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/setup.cfg +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/src/botslab360/auth.py +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/src/botslab360/exceptions.py +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/src/botslab360/quc.py +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/src/botslab360.egg-info/dependency_links.txt +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/src/botslab360.egg-info/requires.txt +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/src/botslab360.egg-info/top_level.txt +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/tests/test_android_360_diagnostic.py +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/tests/test_auth.py +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/tests/test_compare_android_quc.py +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/tests/test_credentials_auth_example.py +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/tests/test_devices.py +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/tests/test_public_robot360_auth_diagnostic.py +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/tests/test_quc.py +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/tests/test_room_diagnostic.py +0 -0
- {botslab360-0.4.2 → botslab360-0.6.0}/tests/test_status.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: botslab360
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Async Python client for Botslab/360 robot vacuums
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Source, https://github.com/placix/python-botslab360
|
|
@@ -26,7 +26,7 @@ This project provides an unofficial Python interface for selected 360 robot vacu
|
|
|
26
26
|
|
|
27
27
|
> [!WARNING]
|
|
28
28
|
> This project is unofficial and not affiliated with Botslab, Qihoo 360 or 360 Smart Home.
|
|
29
|
-
>
|
|
29
|
+
> This project should be considered experimental.
|
|
30
30
|
|
|
31
31
|
## Features
|
|
32
32
|
|
|
@@ -39,6 +39,7 @@ Currently implemented:
|
|
|
39
39
|
- Automatic Smart Home SID refresh
|
|
40
40
|
- Device discovery
|
|
41
41
|
- Robot status retrieval
|
|
42
|
+
- Robot station network-information retrieval
|
|
42
43
|
- TCP / push protocol communication
|
|
43
44
|
- AES decryption of push messages
|
|
44
45
|
- Current room discovery from the robot map
|
|
@@ -191,6 +192,27 @@ async with Botslab360Client(q, t) as client:
|
|
|
191
192
|
await client.locate(robot)
|
|
192
193
|
```
|
|
193
194
|
|
|
195
|
+
### Mop hardware and mop-only mode
|
|
196
|
+
|
|
197
|
+
`RobotStatus.mop_status` exposes the raw integer reported by status command
|
|
198
|
+
`20001`. On the tested S9-P, `0` means that the wiping assembly is absent and
|
|
199
|
+
`1` means that it is present. Other integer values are preserved without an
|
|
200
|
+
assumed meaning because their semantics may be model-dependent.
|
|
201
|
+
|
|
202
|
+
Mop-only mode can be controlled through the public API:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
await client.set_mop_only(robot, False) # Sweep, or sweep and mop with hardware
|
|
206
|
+
await client.set_mop_only(robot, True) # Mop only
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Disabling mop-only sends the verified vendor switch value `1`; enabling it
|
|
210
|
+
sends value `2`. With value `1`, the effective mode depends on whether wiping
|
|
211
|
+
hardware is installed. The current mop-only switch value is not exposed by the
|
|
212
|
+
known status payload, so the library does not fabricate a synchronized
|
|
213
|
+
`mop_only` state or derived cleaning mode. Water level remains an independent
|
|
214
|
+
room setting.
|
|
215
|
+
|
|
194
216
|
### Room cleaning
|
|
195
217
|
|
|
196
218
|
Room cleaning always fetches the current map before validating and sending the
|
|
@@ -217,13 +239,12 @@ map coordinate system (integer millimetres, original point order). Missing or
|
|
|
217
239
|
malformed polygons are exposed as `None`; raw MapInfo data is not exposed.
|
|
218
240
|
|
|
219
241
|
The Android room-attribute UI defines one or two cleaning passes, four suction
|
|
220
|
-
modes, three mopping water levels
|
|
221
|
-
|
|
242
|
+
modes, and three mopping water levels. Optional settings can override these
|
|
243
|
+
verified attributes for selected rooms in one cleaning request:
|
|
222
244
|
|
|
223
245
|
```python
|
|
224
246
|
from botslab360 import (
|
|
225
247
|
RoomCleaningSettings,
|
|
226
|
-
RoomCleaningMode,
|
|
227
248
|
RoomFanMode,
|
|
228
249
|
RoomWaterLevel,
|
|
229
250
|
)
|
|
@@ -233,7 +254,6 @@ await client.clean_rooms(
|
|
|
233
254
|
[1],
|
|
234
255
|
room_settings={
|
|
235
256
|
1: RoomCleaningSettings(
|
|
236
|
-
mode=RoomCleaningMode.SWEEP,
|
|
237
257
|
clean_times=2,
|
|
238
258
|
fan_mode=RoomFanMode.STRONG,
|
|
239
259
|
water_pump=RoomWaterLevel.MEDIUM,
|
|
@@ -250,11 +270,17 @@ Water levels are `1` (low), `2` (medium), and `3` (high). An existing vendor
|
|
|
250
270
|
`waterPump=0` value is preserved when not overridden, but `0` is not exposed as
|
|
251
271
|
an "off" choice because that meaning has not been confirmed.
|
|
252
272
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
273
|
+
`Room.mode` preserves the optional raw `SweepArea.mode` vendor string. Analysis
|
|
274
|
+
of the Android app found carpet-related values such as `mode_big_carpet` and
|
|
275
|
+
`mode_tiny_carpet`; this field is not a verified room sweep/mop selector. The
|
|
276
|
+
deprecated `RoomCleaningMode` compatibility enum and
|
|
277
|
+
`RoomCleaningSettings.mode` field must not be used for new code. Setting
|
|
278
|
+
`RoomCleaningSettings.mode` is rejected rather than writing an unverified value
|
|
279
|
+
to a cleaning request.
|
|
280
|
+
|
|
281
|
+
The Android app also has a separate `SweepStrategy.cleanMode` field with partial
|
|
282
|
+
evidence for mop and sweep values, but its relationship to room-cleaning
|
|
283
|
+
requests has not yet been verified.
|
|
258
284
|
|
|
259
285
|
## Status information
|
|
260
286
|
|
|
@@ -268,6 +294,7 @@ Depending on the robot model, status information may include:
|
|
|
268
294
|
- Cleaning time in seconds
|
|
269
295
|
- Error code
|
|
270
296
|
- Online state
|
|
297
|
+
- Wiping assembly status (`mop_status`)
|
|
271
298
|
|
|
272
299
|
Example:
|
|
273
300
|
|
|
@@ -283,6 +310,23 @@ Cleaning time: 157
|
|
|
283
310
|
Error code: 0
|
|
284
311
|
```
|
|
285
312
|
|
|
313
|
+
## Network information
|
|
314
|
+
|
|
315
|
+
The robot can report its current station network identity through the public
|
|
316
|
+
API. Missing vendor fields are returned as `None`, and MAC addresses are
|
|
317
|
+
normalized to lowercase colon-separated form.
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
network_info = await client.get_network_info(robot)
|
|
321
|
+
|
|
322
|
+
print(network_info.station_ip)
|
|
323
|
+
print(network_info.station_mac)
|
|
324
|
+
print(network_info.station_signal)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Treat SSIDs as potentially sensitive when displaying or logging
|
|
328
|
+
`network_info.station_ssid`.
|
|
329
|
+
|
|
286
330
|
## Session handling
|
|
287
331
|
|
|
288
332
|
The library distinguishes between the Qihoo account session and the Smart Home session.
|
|
@@ -6,7 +6,7 @@ This project provides an unofficial Python interface for selected 360 robot vacu
|
|
|
6
6
|
|
|
7
7
|
> [!WARNING]
|
|
8
8
|
> This project is unofficial and not affiliated with Botslab, Qihoo 360 or 360 Smart Home.
|
|
9
|
-
>
|
|
9
|
+
> This project should be considered experimental.
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
@@ -19,6 +19,7 @@ Currently implemented:
|
|
|
19
19
|
- Automatic Smart Home SID refresh
|
|
20
20
|
- Device discovery
|
|
21
21
|
- Robot status retrieval
|
|
22
|
+
- Robot station network-information retrieval
|
|
22
23
|
- TCP / push protocol communication
|
|
23
24
|
- AES decryption of push messages
|
|
24
25
|
- Current room discovery from the robot map
|
|
@@ -171,6 +172,27 @@ async with Botslab360Client(q, t) as client:
|
|
|
171
172
|
await client.locate(robot)
|
|
172
173
|
```
|
|
173
174
|
|
|
175
|
+
### Mop hardware and mop-only mode
|
|
176
|
+
|
|
177
|
+
`RobotStatus.mop_status` exposes the raw integer reported by status command
|
|
178
|
+
`20001`. On the tested S9-P, `0` means that the wiping assembly is absent and
|
|
179
|
+
`1` means that it is present. Other integer values are preserved without an
|
|
180
|
+
assumed meaning because their semantics may be model-dependent.
|
|
181
|
+
|
|
182
|
+
Mop-only mode can be controlled through the public API:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
await client.set_mop_only(robot, False) # Sweep, or sweep and mop with hardware
|
|
186
|
+
await client.set_mop_only(robot, True) # Mop only
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Disabling mop-only sends the verified vendor switch value `1`; enabling it
|
|
190
|
+
sends value `2`. With value `1`, the effective mode depends on whether wiping
|
|
191
|
+
hardware is installed. The current mop-only switch value is not exposed by the
|
|
192
|
+
known status payload, so the library does not fabricate a synchronized
|
|
193
|
+
`mop_only` state or derived cleaning mode. Water level remains an independent
|
|
194
|
+
room setting.
|
|
195
|
+
|
|
174
196
|
### Room cleaning
|
|
175
197
|
|
|
176
198
|
Room cleaning always fetches the current map before validating and sending the
|
|
@@ -197,13 +219,12 @@ map coordinate system (integer millimetres, original point order). Missing or
|
|
|
197
219
|
malformed polygons are exposed as `None`; raw MapInfo data is not exposed.
|
|
198
220
|
|
|
199
221
|
The Android room-attribute UI defines one or two cleaning passes, four suction
|
|
200
|
-
modes, three mopping water levels
|
|
201
|
-
|
|
222
|
+
modes, and three mopping water levels. Optional settings can override these
|
|
223
|
+
verified attributes for selected rooms in one cleaning request:
|
|
202
224
|
|
|
203
225
|
```python
|
|
204
226
|
from botslab360 import (
|
|
205
227
|
RoomCleaningSettings,
|
|
206
|
-
RoomCleaningMode,
|
|
207
228
|
RoomFanMode,
|
|
208
229
|
RoomWaterLevel,
|
|
209
230
|
)
|
|
@@ -213,7 +234,6 @@ await client.clean_rooms(
|
|
|
213
234
|
[1],
|
|
214
235
|
room_settings={
|
|
215
236
|
1: RoomCleaningSettings(
|
|
216
|
-
mode=RoomCleaningMode.SWEEP,
|
|
217
237
|
clean_times=2,
|
|
218
238
|
fan_mode=RoomFanMode.STRONG,
|
|
219
239
|
water_pump=RoomWaterLevel.MEDIUM,
|
|
@@ -230,11 +250,17 @@ Water levels are `1` (low), `2` (medium), and `3` (high). An existing vendor
|
|
|
230
250
|
`waterPump=0` value is preserved when not overridden, but `0` is not exposed as
|
|
231
251
|
an "off" choice because that meaning has not been confirmed.
|
|
232
252
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
253
|
+
`Room.mode` preserves the optional raw `SweepArea.mode` vendor string. Analysis
|
|
254
|
+
of the Android app found carpet-related values such as `mode_big_carpet` and
|
|
255
|
+
`mode_tiny_carpet`; this field is not a verified room sweep/mop selector. The
|
|
256
|
+
deprecated `RoomCleaningMode` compatibility enum and
|
|
257
|
+
`RoomCleaningSettings.mode` field must not be used for new code. Setting
|
|
258
|
+
`RoomCleaningSettings.mode` is rejected rather than writing an unverified value
|
|
259
|
+
to a cleaning request.
|
|
260
|
+
|
|
261
|
+
The Android app also has a separate `SweepStrategy.cleanMode` field with partial
|
|
262
|
+
evidence for mop and sweep values, but its relationship to room-cleaning
|
|
263
|
+
requests has not yet been verified.
|
|
238
264
|
|
|
239
265
|
## Status information
|
|
240
266
|
|
|
@@ -248,6 +274,7 @@ Depending on the robot model, status information may include:
|
|
|
248
274
|
- Cleaning time in seconds
|
|
249
275
|
- Error code
|
|
250
276
|
- Online state
|
|
277
|
+
- Wiping assembly status (`mop_status`)
|
|
251
278
|
|
|
252
279
|
Example:
|
|
253
280
|
|
|
@@ -263,6 +290,23 @@ Cleaning time: 157
|
|
|
263
290
|
Error code: 0
|
|
264
291
|
```
|
|
265
292
|
|
|
293
|
+
## Network information
|
|
294
|
+
|
|
295
|
+
The robot can report its current station network identity through the public
|
|
296
|
+
API. Missing vendor fields are returned as `None`, and MAC addresses are
|
|
297
|
+
normalized to lowercase colon-separated form.
|
|
298
|
+
|
|
299
|
+
```python
|
|
300
|
+
network_info = await client.get_network_info(robot)
|
|
301
|
+
|
|
302
|
+
print(network_info.station_ip)
|
|
303
|
+
print(network_info.station_mac)
|
|
304
|
+
print(network_info.station_signal)
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Treat SSIDs as potentially sensitive when displaying or logging
|
|
308
|
+
`network_info.station_ssid`.
|
|
309
|
+
|
|
266
310
|
## Session handling
|
|
267
311
|
|
|
268
312
|
The library distinguishes between the Qihoo account session and the Smart Home session.
|
|
@@ -16,6 +16,7 @@ from .models import (
|
|
|
16
16
|
CaptchaChallenge,
|
|
17
17
|
Device,
|
|
18
18
|
DeviceIdentity,
|
|
19
|
+
NetworkInfo,
|
|
19
20
|
QihooCredentials,
|
|
20
21
|
RobotStatus,
|
|
21
22
|
Room,
|
|
@@ -39,6 +40,7 @@ __all__ = [
|
|
|
39
40
|
"Device",
|
|
40
41
|
"DeviceIdentity",
|
|
41
42
|
"InvalidSessionError",
|
|
43
|
+
"NetworkInfo",
|
|
42
44
|
"QihooCredentials",
|
|
43
45
|
"QucAuthenticationError",
|
|
44
46
|
"RobotStatus",
|
|
@@ -17,6 +17,8 @@ import httpx
|
|
|
17
17
|
from .auth import SMART_HOME_BASE_URL, BotslabAuth, credentials_from_tokens
|
|
18
18
|
from .commands import (
|
|
19
19
|
LOCATE,
|
|
20
|
+
MOP_ONLY_OFF,
|
|
21
|
+
MOP_ONLY_ON,
|
|
20
22
|
PAUSE,
|
|
21
23
|
RESUME,
|
|
22
24
|
RETURN_TO_DOCK,
|
|
@@ -29,6 +31,7 @@ from .models import (
|
|
|
29
31
|
CaptchaChallenge,
|
|
30
32
|
Device,
|
|
31
33
|
DeviceIdentity,
|
|
34
|
+
NetworkInfo,
|
|
32
35
|
QihooCredentials,
|
|
33
36
|
RobotStatus,
|
|
34
37
|
Room,
|
|
@@ -45,6 +48,7 @@ from .protocol import (
|
|
|
45
48
|
DEFAULT_PUSH_HEARTBEAT_TIMEOUT,
|
|
46
49
|
DEFAULT_PUSH_HOST,
|
|
47
50
|
DEFAULT_PUSH_PORT,
|
|
51
|
+
NETWORK_INFO_TYPE,
|
|
48
52
|
STATUS_INFO_TYPE,
|
|
49
53
|
PushClient,
|
|
50
54
|
)
|
|
@@ -560,6 +564,60 @@ class Botslab360Client:
|
|
|
560
564
|
)
|
|
561
565
|
return list(room_map.rooms)
|
|
562
566
|
|
|
567
|
+
async def get_network_info(
|
|
568
|
+
self,
|
|
569
|
+
device: Device | str,
|
|
570
|
+
*,
|
|
571
|
+
timeout: float = 30.0,
|
|
572
|
+
) -> NetworkInfo:
|
|
573
|
+
"""Return station network information reported by one robot."""
|
|
574
|
+
|
|
575
|
+
device_id = _device_id(device)
|
|
576
|
+
task_id = str(uuid4())
|
|
577
|
+
return await self._with_session_refresh(
|
|
578
|
+
lambda: self._get_network_info_once(
|
|
579
|
+
device_id=device_id,
|
|
580
|
+
task_id=task_id,
|
|
581
|
+
timeout=timeout,
|
|
582
|
+
)
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
async def _get_network_info_once(
|
|
586
|
+
self,
|
|
587
|
+
*,
|
|
588
|
+
device_id: str,
|
|
589
|
+
task_id: str,
|
|
590
|
+
timeout: float,
|
|
591
|
+
) -> NetworkInfo:
|
|
592
|
+
if self._session is None:
|
|
593
|
+
raise AuthenticationError(
|
|
594
|
+
"Authentication is required before requesting network info",
|
|
595
|
+
phase="authentication",
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
push = PushClient(
|
|
599
|
+
self._session.sid,
|
|
600
|
+
self._session.push_key,
|
|
601
|
+
host=self._push_host,
|
|
602
|
+
port=self._push_port,
|
|
603
|
+
client_version=self._push_client_version,
|
|
604
|
+
heartbeat_timeout=self._push_heartbeat_timeout,
|
|
605
|
+
heartbeat_interval=self._push_heartbeat_interval,
|
|
606
|
+
)
|
|
607
|
+
async with push:
|
|
608
|
+
await self._post_robot_request(
|
|
609
|
+
device_id=device_id,
|
|
610
|
+
info_type=NETWORK_INFO_TYPE,
|
|
611
|
+
data="",
|
|
612
|
+
task_id=task_id,
|
|
613
|
+
operation="network info request",
|
|
614
|
+
)
|
|
615
|
+
return await push.wait_for_network_info(
|
|
616
|
+
device_id=device_id,
|
|
617
|
+
task_id=task_id,
|
|
618
|
+
timeout=timeout,
|
|
619
|
+
)
|
|
620
|
+
|
|
563
621
|
async def clean_rooms(
|
|
564
622
|
self,
|
|
565
623
|
device: Device | str,
|
|
@@ -776,6 +834,14 @@ class Botslab360Client:
|
|
|
776
834
|
|
|
777
835
|
await self._execute_command(device, LOCATE, "locate robot")
|
|
778
836
|
|
|
837
|
+
async def set_mop_only(self, device: Device | str, enabled: bool) -> None:
|
|
838
|
+
"""Enable or disable mop-only cleaning."""
|
|
839
|
+
|
|
840
|
+
if not isinstance(enabled, bool):
|
|
841
|
+
raise TypeError("enabled must be a bool")
|
|
842
|
+
command = MOP_ONLY_ON if enabled else MOP_ONLY_OFF
|
|
843
|
+
await self._execute_command(device, command, "set mop-only mode")
|
|
844
|
+
|
|
779
845
|
async def _execute_command(
|
|
780
846
|
self,
|
|
781
847
|
device: Device | str,
|
|
@@ -26,3 +26,11 @@ LOCATE = CommandSpec(
|
|
|
26
26
|
data='{"ctrlCode":3010}',
|
|
27
27
|
requires_task_id=False,
|
|
28
28
|
)
|
|
29
|
+
MOP_ONLY_OFF = CommandSpec(
|
|
30
|
+
info_type="21024",
|
|
31
|
+
data='{"cmd":"setMopSwitch","value":1}',
|
|
32
|
+
)
|
|
33
|
+
MOP_ONLY_ON = CommandSpec(
|
|
34
|
+
info_type="21024",
|
|
35
|
+
data='{"cmd":"setMopSwitch","value":2}',
|
|
36
|
+
)
|
|
@@ -100,6 +100,21 @@ class Device:
|
|
|
100
100
|
online: bool
|
|
101
101
|
|
|
102
102
|
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class NetworkInfo:
|
|
105
|
+
"""Station network information reported by a Botslab/360 robot."""
|
|
106
|
+
|
|
107
|
+
station_ip: str | None
|
|
108
|
+
station_mac: str | None
|
|
109
|
+
station_ssid: str | None
|
|
110
|
+
station_signal: int | None
|
|
111
|
+
ap_id: str | None = None
|
|
112
|
+
ap_ip: str | None = None
|
|
113
|
+
compile_version: int | None = None
|
|
114
|
+
mcu_version: str | None = None
|
|
115
|
+
rssi: int | None = None
|
|
116
|
+
|
|
117
|
+
|
|
103
118
|
@dataclass(frozen=True, slots=True)
|
|
104
119
|
class Room:
|
|
105
120
|
"""A room from the robot's current smart-area map."""
|
|
@@ -111,7 +126,7 @@ class Room:
|
|
|
111
126
|
fan_mode: str | None
|
|
112
127
|
water_pump: int | None
|
|
113
128
|
vertices: tuple[tuple[int, int], ...] | None = None
|
|
114
|
-
mode:
|
|
129
|
+
mode: str | None = None
|
|
115
130
|
|
|
116
131
|
|
|
117
132
|
class RoomFanMode(str, Enum):
|
|
@@ -132,7 +147,11 @@ class RoomWaterLevel(IntEnum):
|
|
|
132
147
|
|
|
133
148
|
|
|
134
149
|
class RoomCleaningMode(IntEnum):
|
|
135
|
-
"""
|
|
150
|
+
"""Deprecated unverified numeric cleaning-mode values.
|
|
151
|
+
|
|
152
|
+
Retained for import compatibility with 0.4.2. These values must not be
|
|
153
|
+
written to the vendor ``SweepArea.mode`` field.
|
|
154
|
+
"""
|
|
136
155
|
|
|
137
156
|
SWEEP_AND_MOP = 1
|
|
138
157
|
SWEEP = 2
|
|
@@ -144,7 +163,11 @@ ROOM_CLEAN_TIMES = (1, 2)
|
|
|
144
163
|
|
|
145
164
|
@dataclass(frozen=True, slots=True)
|
|
146
165
|
class RoomCleaningSettings:
|
|
147
|
-
"""Optional cleaning attributes for one room.
|
|
166
|
+
"""Optional cleaning attributes for one room.
|
|
167
|
+
|
|
168
|
+
``mode`` is retained for 0.4.2 API compatibility but is unsupported and
|
|
169
|
+
rejected when set because the vendor request field was misidentified.
|
|
170
|
+
"""
|
|
148
171
|
|
|
149
172
|
clean_times: int | None = None
|
|
150
173
|
fan_mode: str | None = None
|
|
@@ -165,6 +188,7 @@ class RobotStatus:
|
|
|
165
188
|
cleaned_area_m2: int | None
|
|
166
189
|
cleaning_time_seconds: int | None
|
|
167
190
|
error_code: int
|
|
191
|
+
mop_status: int | None = None
|
|
168
192
|
|
|
169
193
|
@property
|
|
170
194
|
def cleaned_area(self) -> int | None:
|
|
@@ -4,7 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import base64
|
|
7
|
+
import ipaddress
|
|
7
8
|
import json
|
|
9
|
+
import re
|
|
8
10
|
import time
|
|
9
11
|
from contextlib import suppress
|
|
10
12
|
from dataclasses import dataclass
|
|
@@ -15,7 +17,7 @@ from cryptography.hazmat.primitives import padding
|
|
|
15
17
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
16
18
|
|
|
17
19
|
from .exceptions import ApiError
|
|
18
|
-
from .models import RobotStatus
|
|
20
|
+
from .models import NetworkInfo, RobotStatus
|
|
19
21
|
|
|
20
22
|
DEFAULT_PUSH_HOST = "47.254.151.104"
|
|
21
23
|
DEFAULT_PUSH_PORT = 443
|
|
@@ -28,10 +30,13 @@ ANDROID_360_PUSH_HEARTBEAT_TIMEOUT = 20
|
|
|
28
30
|
ANDROID_360_PUSH_HEARTBEAT_INTERVAL = 15.0
|
|
29
31
|
PUSH_PRODUCT = "60009"
|
|
30
32
|
STATUS_INFO_TYPE = "20001"
|
|
33
|
+
NETWORK_INFO_TYPE = "21019"
|
|
34
|
+
COMMAND_RESPONSE_EVENT = "10"
|
|
31
35
|
PUSH_PROTOCOL_VERSION = 5
|
|
32
36
|
PUSH_BIND_ACK_OPCODE = 6
|
|
33
37
|
PUSH_MESSAGE_OPCODE = 3
|
|
34
38
|
PUSH_READY_TIMEOUT = 15.0
|
|
39
|
+
_MAC_PATTERN = re.compile(r"(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}|[0-9a-fA-F]{12}")
|
|
35
40
|
|
|
36
41
|
|
|
37
42
|
def _protocol_error(message: str, *, phase: str = "protocol") -> ApiError:
|
|
@@ -120,6 +125,81 @@ def _optional_int(value: object, *, field: str) -> int | None:
|
|
|
120
125
|
return value
|
|
121
126
|
|
|
122
127
|
|
|
128
|
+
def _optional_text(value: object, *, field: str) -> str | None:
|
|
129
|
+
if value in (None, ""):
|
|
130
|
+
return None
|
|
131
|
+
if not isinstance(value, str):
|
|
132
|
+
raise _protocol_error(f"Network info field {field} is invalid")
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _optional_ip(value: object, *, field: str) -> str | None:
|
|
137
|
+
text = _optional_text(value, field=field)
|
|
138
|
+
if text is None:
|
|
139
|
+
return None
|
|
140
|
+
try:
|
|
141
|
+
return str(ipaddress.ip_address(text))
|
|
142
|
+
except ValueError as exc:
|
|
143
|
+
raise _protocol_error(f"Network info field {field} is invalid") from exc
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _optional_mac(value: object, *, field: str) -> str | None:
|
|
147
|
+
text = _optional_text(value, field=field)
|
|
148
|
+
if text is None:
|
|
149
|
+
return None
|
|
150
|
+
if _MAC_PATTERN.fullmatch(text) is None:
|
|
151
|
+
raise _protocol_error(f"Network info field {field} is invalid")
|
|
152
|
+
compact = text.replace(":", "").replace("-", "").lower()
|
|
153
|
+
return ":".join(compact[index : index + 2] for index in range(0, 12, 2))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _optional_network_int(value: object, *, field: str) -> int | None:
|
|
157
|
+
if value is None:
|
|
158
|
+
return None
|
|
159
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
160
|
+
raise _protocol_error(f"Network info field {field} is invalid")
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse_network_info_event(
|
|
165
|
+
event: object,
|
|
166
|
+
*,
|
|
167
|
+
device_id: str,
|
|
168
|
+
task_id: str,
|
|
169
|
+
) -> NetworkInfo | None:
|
|
170
|
+
"""Return network info only for a matching command response."""
|
|
171
|
+
|
|
172
|
+
if not isinstance(event, dict):
|
|
173
|
+
raise _protocol_error("Push event is invalid")
|
|
174
|
+
if event.get("sn") != device_id or event.get("taskid") != task_id:
|
|
175
|
+
return None
|
|
176
|
+
if str(event.get("event")) != COMMAND_RESPONSE_EVENT:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
protocol = _json_object(event.get("data"), field="protocol")
|
|
180
|
+
if str(protocol.get("infoType")) != NETWORK_INFO_TYPE:
|
|
181
|
+
return None
|
|
182
|
+
payload = _json_object(protocol.get("data"), field="network info data")
|
|
183
|
+
|
|
184
|
+
return NetworkInfo(
|
|
185
|
+
station_ip=_optional_ip(payload.get("staIp"), field="staIp"),
|
|
186
|
+
station_mac=_optional_mac(payload.get("staMac"), field="staMac"),
|
|
187
|
+
station_ssid=_optional_text(payload.get("staId"), field="staId"),
|
|
188
|
+
station_signal=_optional_network_int(
|
|
189
|
+
payload.get("staSignal"),
|
|
190
|
+
field="staSignal",
|
|
191
|
+
),
|
|
192
|
+
ap_id=_optional_text(payload.get("apId"), field="apId"),
|
|
193
|
+
ap_ip=_optional_ip(payload.get("apIp"), field="apIp"),
|
|
194
|
+
compile_version=_optional_network_int(
|
|
195
|
+
payload.get("compileVer"),
|
|
196
|
+
field="compileVer",
|
|
197
|
+
),
|
|
198
|
+
mcu_version=_optional_text(payload.get("mcuVer"), field="mcuVer"),
|
|
199
|
+
rssi=_optional_network_int(payload.get("rssi"), field="rssi"),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
123
203
|
def parse_status_event(
|
|
124
204
|
event: object,
|
|
125
205
|
*,
|
|
@@ -178,6 +258,7 @@ def parse_status_event(
|
|
|
178
258
|
field="cleanTime",
|
|
179
259
|
),
|
|
180
260
|
error_code=error_code,
|
|
261
|
+
mop_status=_optional_int(status.get("mopStatus"), field="mopStatus"),
|
|
181
262
|
)
|
|
182
263
|
|
|
183
264
|
|
|
@@ -619,6 +700,45 @@ class PushClient:
|
|
|
619
700
|
phase="timeout",
|
|
620
701
|
) from exc
|
|
621
702
|
|
|
703
|
+
async def wait_for_network_info(
|
|
704
|
+
self,
|
|
705
|
+
*,
|
|
706
|
+
device_id: str,
|
|
707
|
+
task_id: str,
|
|
708
|
+
timeout: float,
|
|
709
|
+
) -> NetworkInfo:
|
|
710
|
+
"""Wait for one matching network-info command response."""
|
|
711
|
+
|
|
712
|
+
try:
|
|
713
|
+
return await asyncio.wait_for(
|
|
714
|
+
self._wait_for_network_info(device_id=device_id, task_id=task_id),
|
|
715
|
+
timeout=timeout,
|
|
716
|
+
)
|
|
717
|
+
except asyncio.TimeoutError as exc:
|
|
718
|
+
raise _protocol_error(
|
|
719
|
+
"Timed out waiting for robot network info",
|
|
720
|
+
phase="timeout",
|
|
721
|
+
) from exc
|
|
722
|
+
|
|
723
|
+
async def _wait_for_network_info(
|
|
724
|
+
self,
|
|
725
|
+
*,
|
|
726
|
+
device_id: str,
|
|
727
|
+
task_id: str,
|
|
728
|
+
) -> NetworkInfo:
|
|
729
|
+
if self._reader is None or self._writer is None:
|
|
730
|
+
raise _protocol_error("Push client is not connected", phase="transport")
|
|
731
|
+
|
|
732
|
+
while True:
|
|
733
|
+
event = await self.read_event()
|
|
734
|
+
network_info = parse_network_info_event(
|
|
735
|
+
event,
|
|
736
|
+
device_id=device_id,
|
|
737
|
+
task_id=task_id,
|
|
738
|
+
)
|
|
739
|
+
if network_info is not None:
|
|
740
|
+
return network_info
|
|
741
|
+
|
|
622
742
|
async def _wait_for_status(
|
|
623
743
|
self,
|
|
624
744
|
*,
|
|
@@ -18,7 +18,6 @@ from .exceptions import ApiError
|
|
|
18
18
|
from .models import (
|
|
19
19
|
ROOM_CLEAN_TIMES,
|
|
20
20
|
Room,
|
|
21
|
-
RoomCleaningMode,
|
|
22
21
|
RoomCleaningSettings,
|
|
23
22
|
RoomFanMode,
|
|
24
23
|
RoomWaterLevel,
|
|
@@ -74,7 +73,6 @@ class RoomMap:
|
|
|
74
73
|
|
|
75
74
|
_ROOM_FAN_MODE_VALUES = frozenset(mode.value for mode in RoomFanMode)
|
|
76
75
|
_ROOM_WATER_LEVEL_VALUES = frozenset(level.value for level in RoomWaterLevel)
|
|
77
|
-
_ROOM_CLEANING_MODE_VALUES = frozenset(mode.value for mode in RoomCleaningMode)
|
|
78
76
|
|
|
79
77
|
|
|
80
78
|
def validate_room_cleaning_settings(settings: object) -> RoomCleaningSettings:
|
|
@@ -104,8 +102,10 @@ def validate_room_cleaning_settings(settings: object) -> RoomCleaningSettings:
|
|
|
104
102
|
if settings.mode is not None:
|
|
105
103
|
if isinstance(settings.mode, bool) or not isinstance(settings.mode, int):
|
|
106
104
|
raise TypeError("mode must be an integer")
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
raise ValueError(
|
|
106
|
+
"mode overrides are unsupported because SweepArea.mode is not a "
|
|
107
|
+
"verified cleaning-mode field"
|
|
108
|
+
)
|
|
109
109
|
return settings
|
|
110
110
|
|
|
111
111
|
|
|
@@ -280,7 +280,7 @@ def parse_room_map(map_info: object) -> RoomMap:
|
|
|
280
280
|
fan_mode=_optional_str(area.get("windMode")),
|
|
281
281
|
water_pump=_optional_int(area.get("waterPump")),
|
|
282
282
|
vertices=_room_vertices(area.get("vertexs")),
|
|
283
|
-
mode=
|
|
283
|
+
mode=_optional_str(area.get("mode")),
|
|
284
284
|
)
|
|
285
285
|
)
|
|
286
286
|
return RoomMap(
|
|
@@ -307,8 +307,6 @@ def _apply_room_settings(
|
|
|
307
307
|
area["windMode"] = settings.fan_mode
|
|
308
308
|
if settings.water_pump is not None:
|
|
309
309
|
area["waterPump"] = settings.water_pump
|
|
310
|
-
if settings.mode is not None:
|
|
311
|
-
area["mode"] = settings.mode
|
|
312
310
|
|
|
313
311
|
|
|
314
312
|
def prepare_area_setting(
|