botslab360 0.4.2__tar.gz → 0.5.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.5.0}/PKG-INFO +33 -11
- {botslab360-0.4.2 → botslab360-0.5.0}/README.md +32 -10
- {botslab360-0.4.2 → botslab360-0.5.0}/pyproject.toml +1 -1
- {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/__init__.py +2 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/client.py +56 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/models.py +26 -3
- {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/protocol.py +120 -1
- {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/rooms.py +5 -7
- {botslab360-0.4.2 → botslab360-0.5.0/src/botslab360.egg-info}/PKG-INFO +33 -11
- {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360.egg-info/SOURCES.txt +2 -0
- botslab360-0.5.0/tests/test_network_info.py +212 -0
- botslab360-0.5.0/tests/test_network_info_diagnostic.py +131 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_public_room_api_diagnostic.py +77 -1
- {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_rooms.py +39 -16
- {botslab360-0.4.2 → botslab360-0.5.0}/ATTRIBUTION.md +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/LICENSE +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/setup.cfg +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/auth.py +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/commands.py +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/exceptions.py +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/quc.py +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360.egg-info/dependency_links.txt +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360.egg-info/requires.txt +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360.egg-info/top_level.txt +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_android_360_diagnostic.py +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_auth.py +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_commands.py +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_compare_android_quc.py +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_credentials_auth_example.py +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_devices.py +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_protocol.py +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_public_robot360_auth_diagnostic.py +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_quc.py +0 -0
- {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_room_diagnostic.py +0 -0
- {botslab360-0.4.2 → botslab360-0.5.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.5.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
|
|
@@ -217,13 +218,12 @@ map coordinate system (integer millimetres, original point order). Missing or
|
|
|
217
218
|
malformed polygons are exposed as `None`; raw MapInfo data is not exposed.
|
|
218
219
|
|
|
219
220
|
The Android room-attribute UI defines one or two cleaning passes, four suction
|
|
220
|
-
modes, three mopping water levels
|
|
221
|
-
|
|
221
|
+
modes, and three mopping water levels. Optional settings can override these
|
|
222
|
+
verified attributes for selected rooms in one cleaning request:
|
|
222
223
|
|
|
223
224
|
```python
|
|
224
225
|
from botslab360 import (
|
|
225
226
|
RoomCleaningSettings,
|
|
226
|
-
RoomCleaningMode,
|
|
227
227
|
RoomFanMode,
|
|
228
228
|
RoomWaterLevel,
|
|
229
229
|
)
|
|
@@ -233,7 +233,6 @@ await client.clean_rooms(
|
|
|
233
233
|
[1],
|
|
234
234
|
room_settings={
|
|
235
235
|
1: RoomCleaningSettings(
|
|
236
|
-
mode=RoomCleaningMode.SWEEP,
|
|
237
236
|
clean_times=2,
|
|
238
237
|
fan_mode=RoomFanMode.STRONG,
|
|
239
238
|
water_pump=RoomWaterLevel.MEDIUM,
|
|
@@ -250,11 +249,17 @@ Water levels are `1` (low), `2` (medium), and `3` (high). An existing vendor
|
|
|
250
249
|
`waterPump=0` value is preserved when not overridden, but `0` is not exposed as
|
|
251
250
|
an "off" choice because that meaning has not been confirmed.
|
|
252
251
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
252
|
+
`Room.mode` preserves the optional raw `SweepArea.mode` vendor string. Analysis
|
|
253
|
+
of the Android app found carpet-related values such as `mode_big_carpet` and
|
|
254
|
+
`mode_tiny_carpet`; this field is not a verified room sweep/mop selector. The
|
|
255
|
+
deprecated `RoomCleaningMode` compatibility enum and
|
|
256
|
+
`RoomCleaningSettings.mode` field must not be used for new code. Setting
|
|
257
|
+
`RoomCleaningSettings.mode` is rejected rather than writing an unverified value
|
|
258
|
+
to a cleaning request.
|
|
259
|
+
|
|
260
|
+
The Android app also has a separate `SweepStrategy.cleanMode` field with partial
|
|
261
|
+
evidence for mop and sweep values, but its relationship to room-cleaning
|
|
262
|
+
requests has not yet been verified.
|
|
258
263
|
|
|
259
264
|
## Status information
|
|
260
265
|
|
|
@@ -283,6 +288,23 @@ Cleaning time: 157
|
|
|
283
288
|
Error code: 0
|
|
284
289
|
```
|
|
285
290
|
|
|
291
|
+
## Network information
|
|
292
|
+
|
|
293
|
+
The robot can report its current station network identity through the public
|
|
294
|
+
API. Missing vendor fields are returned as `None`, and MAC addresses are
|
|
295
|
+
normalized to lowercase colon-separated form.
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
network_info = await client.get_network_info(robot)
|
|
299
|
+
|
|
300
|
+
print(network_info.station_ip)
|
|
301
|
+
print(network_info.station_mac)
|
|
302
|
+
print(network_info.station_signal)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Treat SSIDs as potentially sensitive when displaying or logging
|
|
306
|
+
`network_info.station_ssid`.
|
|
307
|
+
|
|
286
308
|
## Session handling
|
|
287
309
|
|
|
288
310
|
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
|
|
@@ -197,13 +198,12 @@ map coordinate system (integer millimetres, original point order). Missing or
|
|
|
197
198
|
malformed polygons are exposed as `None`; raw MapInfo data is not exposed.
|
|
198
199
|
|
|
199
200
|
The Android room-attribute UI defines one or two cleaning passes, four suction
|
|
200
|
-
modes, three mopping water levels
|
|
201
|
-
|
|
201
|
+
modes, and three mopping water levels. Optional settings can override these
|
|
202
|
+
verified attributes for selected rooms in one cleaning request:
|
|
202
203
|
|
|
203
204
|
```python
|
|
204
205
|
from botslab360 import (
|
|
205
206
|
RoomCleaningSettings,
|
|
206
|
-
RoomCleaningMode,
|
|
207
207
|
RoomFanMode,
|
|
208
208
|
RoomWaterLevel,
|
|
209
209
|
)
|
|
@@ -213,7 +213,6 @@ await client.clean_rooms(
|
|
|
213
213
|
[1],
|
|
214
214
|
room_settings={
|
|
215
215
|
1: RoomCleaningSettings(
|
|
216
|
-
mode=RoomCleaningMode.SWEEP,
|
|
217
216
|
clean_times=2,
|
|
218
217
|
fan_mode=RoomFanMode.STRONG,
|
|
219
218
|
water_pump=RoomWaterLevel.MEDIUM,
|
|
@@ -230,11 +229,17 @@ Water levels are `1` (low), `2` (medium), and `3` (high). An existing vendor
|
|
|
230
229
|
`waterPump=0` value is preserved when not overridden, but `0` is not exposed as
|
|
231
230
|
an "off" choice because that meaning has not been confirmed.
|
|
232
231
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
232
|
+
`Room.mode` preserves the optional raw `SweepArea.mode` vendor string. Analysis
|
|
233
|
+
of the Android app found carpet-related values such as `mode_big_carpet` and
|
|
234
|
+
`mode_tiny_carpet`; this field is not a verified room sweep/mop selector. The
|
|
235
|
+
deprecated `RoomCleaningMode` compatibility enum and
|
|
236
|
+
`RoomCleaningSettings.mode` field must not be used for new code. Setting
|
|
237
|
+
`RoomCleaningSettings.mode` is rejected rather than writing an unverified value
|
|
238
|
+
to a cleaning request.
|
|
239
|
+
|
|
240
|
+
The Android app also has a separate `SweepStrategy.cleanMode` field with partial
|
|
241
|
+
evidence for mop and sweep values, but its relationship to room-cleaning
|
|
242
|
+
requests has not yet been verified.
|
|
238
243
|
|
|
239
244
|
## Status information
|
|
240
245
|
|
|
@@ -263,6 +268,23 @@ Cleaning time: 157
|
|
|
263
268
|
Error code: 0
|
|
264
269
|
```
|
|
265
270
|
|
|
271
|
+
## Network information
|
|
272
|
+
|
|
273
|
+
The robot can report its current station network identity through the public
|
|
274
|
+
API. Missing vendor fields are returned as `None`, and MAC addresses are
|
|
275
|
+
normalized to lowercase colon-separated form.
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
network_info = await client.get_network_info(robot)
|
|
279
|
+
|
|
280
|
+
print(network_info.station_ip)
|
|
281
|
+
print(network_info.station_mac)
|
|
282
|
+
print(network_info.station_signal)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Treat SSIDs as potentially sensitive when displaying or logging
|
|
286
|
+
`network_info.station_ssid`.
|
|
287
|
+
|
|
266
288
|
## Session handling
|
|
267
289
|
|
|
268
290
|
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",
|
|
@@ -29,6 +29,7 @@ from .models import (
|
|
|
29
29
|
CaptchaChallenge,
|
|
30
30
|
Device,
|
|
31
31
|
DeviceIdentity,
|
|
32
|
+
NetworkInfo,
|
|
32
33
|
QihooCredentials,
|
|
33
34
|
RobotStatus,
|
|
34
35
|
Room,
|
|
@@ -45,6 +46,7 @@ from .protocol import (
|
|
|
45
46
|
DEFAULT_PUSH_HEARTBEAT_TIMEOUT,
|
|
46
47
|
DEFAULT_PUSH_HOST,
|
|
47
48
|
DEFAULT_PUSH_PORT,
|
|
49
|
+
NETWORK_INFO_TYPE,
|
|
48
50
|
STATUS_INFO_TYPE,
|
|
49
51
|
PushClient,
|
|
50
52
|
)
|
|
@@ -560,6 +562,60 @@ class Botslab360Client:
|
|
|
560
562
|
)
|
|
561
563
|
return list(room_map.rooms)
|
|
562
564
|
|
|
565
|
+
async def get_network_info(
|
|
566
|
+
self,
|
|
567
|
+
device: Device | str,
|
|
568
|
+
*,
|
|
569
|
+
timeout: float = 30.0,
|
|
570
|
+
) -> NetworkInfo:
|
|
571
|
+
"""Return station network information reported by one robot."""
|
|
572
|
+
|
|
573
|
+
device_id = _device_id(device)
|
|
574
|
+
task_id = str(uuid4())
|
|
575
|
+
return await self._with_session_refresh(
|
|
576
|
+
lambda: self._get_network_info_once(
|
|
577
|
+
device_id=device_id,
|
|
578
|
+
task_id=task_id,
|
|
579
|
+
timeout=timeout,
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
async def _get_network_info_once(
|
|
584
|
+
self,
|
|
585
|
+
*,
|
|
586
|
+
device_id: str,
|
|
587
|
+
task_id: str,
|
|
588
|
+
timeout: float,
|
|
589
|
+
) -> NetworkInfo:
|
|
590
|
+
if self._session is None:
|
|
591
|
+
raise AuthenticationError(
|
|
592
|
+
"Authentication is required before requesting network info",
|
|
593
|
+
phase="authentication",
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
push = PushClient(
|
|
597
|
+
self._session.sid,
|
|
598
|
+
self._session.push_key,
|
|
599
|
+
host=self._push_host,
|
|
600
|
+
port=self._push_port,
|
|
601
|
+
client_version=self._push_client_version,
|
|
602
|
+
heartbeat_timeout=self._push_heartbeat_timeout,
|
|
603
|
+
heartbeat_interval=self._push_heartbeat_interval,
|
|
604
|
+
)
|
|
605
|
+
async with push:
|
|
606
|
+
await self._post_robot_request(
|
|
607
|
+
device_id=device_id,
|
|
608
|
+
info_type=NETWORK_INFO_TYPE,
|
|
609
|
+
data="",
|
|
610
|
+
task_id=task_id,
|
|
611
|
+
operation="network info request",
|
|
612
|
+
)
|
|
613
|
+
return await push.wait_for_network_info(
|
|
614
|
+
device_id=device_id,
|
|
615
|
+
task_id=task_id,
|
|
616
|
+
timeout=timeout,
|
|
617
|
+
)
|
|
618
|
+
|
|
563
619
|
async def clean_rooms(
|
|
564
620
|
self,
|
|
565
621
|
device: Device | str,
|
|
@@ -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
|
|
@@ -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
|
*,
|
|
@@ -619,6 +699,45 @@ class PushClient:
|
|
|
619
699
|
phase="timeout",
|
|
620
700
|
) from exc
|
|
621
701
|
|
|
702
|
+
async def wait_for_network_info(
|
|
703
|
+
self,
|
|
704
|
+
*,
|
|
705
|
+
device_id: str,
|
|
706
|
+
task_id: str,
|
|
707
|
+
timeout: float,
|
|
708
|
+
) -> NetworkInfo:
|
|
709
|
+
"""Wait for one matching network-info command response."""
|
|
710
|
+
|
|
711
|
+
try:
|
|
712
|
+
return await asyncio.wait_for(
|
|
713
|
+
self._wait_for_network_info(device_id=device_id, task_id=task_id),
|
|
714
|
+
timeout=timeout,
|
|
715
|
+
)
|
|
716
|
+
except asyncio.TimeoutError as exc:
|
|
717
|
+
raise _protocol_error(
|
|
718
|
+
"Timed out waiting for robot network info",
|
|
719
|
+
phase="timeout",
|
|
720
|
+
) from exc
|
|
721
|
+
|
|
722
|
+
async def _wait_for_network_info(
|
|
723
|
+
self,
|
|
724
|
+
*,
|
|
725
|
+
device_id: str,
|
|
726
|
+
task_id: str,
|
|
727
|
+
) -> NetworkInfo:
|
|
728
|
+
if self._reader is None or self._writer is None:
|
|
729
|
+
raise _protocol_error("Push client is not connected", phase="transport")
|
|
730
|
+
|
|
731
|
+
while True:
|
|
732
|
+
event = await self.read_event()
|
|
733
|
+
network_info = parse_network_info_event(
|
|
734
|
+
event,
|
|
735
|
+
device_id=device_id,
|
|
736
|
+
task_id=task_id,
|
|
737
|
+
)
|
|
738
|
+
if network_info is not None:
|
|
739
|
+
return network_info
|
|
740
|
+
|
|
622
741
|
async def _wait_for_status(
|
|
623
742
|
self,
|
|
624
743
|
*,
|
|
@@ -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(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: botslab360
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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
|
|
@@ -217,13 +218,12 @@ map coordinate system (integer millimetres, original point order). Missing or
|
|
|
217
218
|
malformed polygons are exposed as `None`; raw MapInfo data is not exposed.
|
|
218
219
|
|
|
219
220
|
The Android room-attribute UI defines one or two cleaning passes, four suction
|
|
220
|
-
modes, three mopping water levels
|
|
221
|
-
|
|
221
|
+
modes, and three mopping water levels. Optional settings can override these
|
|
222
|
+
verified attributes for selected rooms in one cleaning request:
|
|
222
223
|
|
|
223
224
|
```python
|
|
224
225
|
from botslab360 import (
|
|
225
226
|
RoomCleaningSettings,
|
|
226
|
-
RoomCleaningMode,
|
|
227
227
|
RoomFanMode,
|
|
228
228
|
RoomWaterLevel,
|
|
229
229
|
)
|
|
@@ -233,7 +233,6 @@ await client.clean_rooms(
|
|
|
233
233
|
[1],
|
|
234
234
|
room_settings={
|
|
235
235
|
1: RoomCleaningSettings(
|
|
236
|
-
mode=RoomCleaningMode.SWEEP,
|
|
237
236
|
clean_times=2,
|
|
238
237
|
fan_mode=RoomFanMode.STRONG,
|
|
239
238
|
water_pump=RoomWaterLevel.MEDIUM,
|
|
@@ -250,11 +249,17 @@ Water levels are `1` (low), `2` (medium), and `3` (high). An existing vendor
|
|
|
250
249
|
`waterPump=0` value is preserved when not overridden, but `0` is not exposed as
|
|
251
250
|
an "off" choice because that meaning has not been confirmed.
|
|
252
251
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
252
|
+
`Room.mode` preserves the optional raw `SweepArea.mode` vendor string. Analysis
|
|
253
|
+
of the Android app found carpet-related values such as `mode_big_carpet` and
|
|
254
|
+
`mode_tiny_carpet`; this field is not a verified room sweep/mop selector. The
|
|
255
|
+
deprecated `RoomCleaningMode` compatibility enum and
|
|
256
|
+
`RoomCleaningSettings.mode` field must not be used for new code. Setting
|
|
257
|
+
`RoomCleaningSettings.mode` is rejected rather than writing an unverified value
|
|
258
|
+
to a cleaning request.
|
|
259
|
+
|
|
260
|
+
The Android app also has a separate `SweepStrategy.cleanMode` field with partial
|
|
261
|
+
evidence for mop and sweep values, but its relationship to room-cleaning
|
|
262
|
+
requests has not yet been verified.
|
|
258
263
|
|
|
259
264
|
## Status information
|
|
260
265
|
|
|
@@ -283,6 +288,23 @@ Cleaning time: 157
|
|
|
283
288
|
Error code: 0
|
|
284
289
|
```
|
|
285
290
|
|
|
291
|
+
## Network information
|
|
292
|
+
|
|
293
|
+
The robot can report its current station network identity through the public
|
|
294
|
+
API. Missing vendor fields are returned as `None`, and MAC addresses are
|
|
295
|
+
normalized to lowercase colon-separated form.
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
network_info = await client.get_network_info(robot)
|
|
299
|
+
|
|
300
|
+
print(network_info.station_ip)
|
|
301
|
+
print(network_info.station_mac)
|
|
302
|
+
print(network_info.station_signal)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Treat SSIDs as potentially sensitive when displaying or logging
|
|
306
|
+
`network_info.station_ssid`.
|
|
307
|
+
|
|
286
308
|
## Session handling
|
|
287
309
|
|
|
288
310
|
The library distinguishes between the Qihoo account session and the Smart Home session.
|
|
@@ -22,6 +22,8 @@ tests/test_commands.py
|
|
|
22
22
|
tests/test_compare_android_quc.py
|
|
23
23
|
tests/test_credentials_auth_example.py
|
|
24
24
|
tests/test_devices.py
|
|
25
|
+
tests/test_network_info.py
|
|
26
|
+
tests/test_network_info_diagnostic.py
|
|
25
27
|
tests/test_protocol.py
|
|
26
28
|
tests/test_public_robot360_auth_diagnostic.py
|
|
27
29
|
tests/test_public_room_api_diagnostic.py
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from urllib.parse import parse_qs
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import botslab360.client as client_module
|
|
12
|
+
from botslab360 import ApiError, Botslab360Client, NetworkInfo
|
|
13
|
+
from botslab360.protocol import parse_network_info_event
|
|
14
|
+
|
|
15
|
+
QID = "1234567890"
|
|
16
|
+
Q = f"u=360H{QID}&n=synthetic&m=not-a-real-token"
|
|
17
|
+
T = "s=synthetic-session&t=1700000000&v=2.0"
|
|
18
|
+
SID = "synthetic-smart-sid"
|
|
19
|
+
PUSH_KEY = "synthetic-push-key"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _event(
|
|
23
|
+
payload: object,
|
|
24
|
+
*,
|
|
25
|
+
device_id: str = "synthetic-device",
|
|
26
|
+
task_id: str = "synthetic-task",
|
|
27
|
+
info_type: str = "21019",
|
|
28
|
+
event: int = 10,
|
|
29
|
+
) -> dict[str, object]:
|
|
30
|
+
return {
|
|
31
|
+
"event": event,
|
|
32
|
+
"sn": device_id,
|
|
33
|
+
"taskid": task_id,
|
|
34
|
+
"data": json.dumps({"infoType": info_type, "data": payload}),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_parse_network_info_response_and_normalize_mac() -> None:
|
|
39
|
+
result = parse_network_info_event(
|
|
40
|
+
_event(
|
|
41
|
+
json.dumps(
|
|
42
|
+
{
|
|
43
|
+
"staIp": "192.168.1.176",
|
|
44
|
+
"staMac": "B0-59-47-C1-2E-C1",
|
|
45
|
+
"staId": "Private SSID",
|
|
46
|
+
"staSignal": -51,
|
|
47
|
+
"apId": "robot-ap",
|
|
48
|
+
"apIp": "192.168.10.1",
|
|
49
|
+
"compileVer": 123,
|
|
50
|
+
"mcuVer": "456",
|
|
51
|
+
"rssi": -52,
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
),
|
|
55
|
+
device_id="synthetic-device",
|
|
56
|
+
task_id="synthetic-task",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
assert result == NetworkInfo(
|
|
60
|
+
station_ip="192.168.1.176",
|
|
61
|
+
station_mac="b0:59:47:c1:2e:c1",
|
|
62
|
+
station_ssid="Private SSID",
|
|
63
|
+
station_signal=-51,
|
|
64
|
+
ap_id="robot-ap",
|
|
65
|
+
ap_ip="192.168.10.1",
|
|
66
|
+
compile_version=123,
|
|
67
|
+
mcu_version="456",
|
|
68
|
+
rssi=-52,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_parse_network_info_allows_missing_optional_fields() -> None:
|
|
73
|
+
assert parse_network_info_event(
|
|
74
|
+
_event({}),
|
|
75
|
+
device_id="synthetic-device",
|
|
76
|
+
task_id="synthetic-task",
|
|
77
|
+
) == NetworkInfo(None, None, None, None)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize(
|
|
81
|
+
("field", "value"),
|
|
82
|
+
[
|
|
83
|
+
("staIp", "not-an-ip"),
|
|
84
|
+
("staMac", "not-a-mac"),
|
|
85
|
+
("staId", 123),
|
|
86
|
+
("staSignal", "strong"),
|
|
87
|
+
("compileVer", True),
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
def test_parse_network_info_rejects_malformed_optional_values(field, value) -> None:
|
|
91
|
+
with pytest.raises(ApiError) as raised:
|
|
92
|
+
parse_network_info_event(
|
|
93
|
+
_event({field: value}),
|
|
94
|
+
device_id="synthetic-device",
|
|
95
|
+
task_id="synthetic-task",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
assert raised.value.phase == "protocol"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_parse_network_info_requires_matching_correlation() -> None:
|
|
102
|
+
payload = {"staIp": "192.168.1.176"}
|
|
103
|
+
assert (
|
|
104
|
+
parse_network_info_event(
|
|
105
|
+
_event(payload, task_id="other"),
|
|
106
|
+
device_id="synthetic-device",
|
|
107
|
+
task_id="synthetic-task",
|
|
108
|
+
)
|
|
109
|
+
is None
|
|
110
|
+
)
|
|
111
|
+
assert (
|
|
112
|
+
parse_network_info_event(
|
|
113
|
+
_event(payload, info_type="20001"),
|
|
114
|
+
device_id="synthetic-device",
|
|
115
|
+
task_id="synthetic-task",
|
|
116
|
+
)
|
|
117
|
+
is None
|
|
118
|
+
)
|
|
119
|
+
assert (
|
|
120
|
+
parse_network_info_event(
|
|
121
|
+
_event(payload, event=4),
|
|
122
|
+
device_id="synthetic-device",
|
|
123
|
+
task_id="synthetic-task",
|
|
124
|
+
)
|
|
125
|
+
is None
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_get_network_info_posts_21019_and_waits_for_push(monkeypatch) -> None:
|
|
130
|
+
async def scenario() -> None:
|
|
131
|
+
events: list[str] = []
|
|
132
|
+
expected = NetworkInfo(
|
|
133
|
+
"192.168.1.176",
|
|
134
|
+
"b0:59:47:c1:2e:c1",
|
|
135
|
+
None,
|
|
136
|
+
-50,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
class FakePushClient:
|
|
140
|
+
def __init__(self, sid, push_key, **kwargs):
|
|
141
|
+
assert sid == SID
|
|
142
|
+
assert push_key == PUSH_KEY
|
|
143
|
+
|
|
144
|
+
async def __aenter__(self):
|
|
145
|
+
events.append("push-connected")
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
async def __aexit__(self, *args):
|
|
149
|
+
events.append("push-closed")
|
|
150
|
+
|
|
151
|
+
async def wait_for_network_info(self, *, device_id, task_id, timeout):
|
|
152
|
+
assert events == ["push-connected", "http-requested"]
|
|
153
|
+
assert device_id == "synthetic-device"
|
|
154
|
+
UUID(task_id)
|
|
155
|
+
assert timeout == 7.5
|
|
156
|
+
return expected
|
|
157
|
+
|
|
158
|
+
monkeypatch.setattr(client_module, "PushClient", FakePushClient)
|
|
159
|
+
|
|
160
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
161
|
+
if request.url.path == "/common/user/login":
|
|
162
|
+
return httpx.Response(
|
|
163
|
+
200,
|
|
164
|
+
json={"errno": 0, "data": {"sid": SID, "pushKey": PUSH_KEY}},
|
|
165
|
+
)
|
|
166
|
+
events.append("http-requested")
|
|
167
|
+
form = parse_qs((await request.aread()).decode(), keep_blank_values=True)
|
|
168
|
+
assert request.url.path == "/clean/cmd/send"
|
|
169
|
+
assert form["infoType"] == ["21019"]
|
|
170
|
+
assert form["data"] == [""]
|
|
171
|
+
assert form["sn"] == ["synthetic-device"]
|
|
172
|
+
UUID(form["taskid"][0])
|
|
173
|
+
return httpx.Response(200, json={"errno": 0})
|
|
174
|
+
|
|
175
|
+
async with httpx.AsyncClient(
|
|
176
|
+
transport=httpx.MockTransport(handler)
|
|
177
|
+
) as http_client:
|
|
178
|
+
client = Botslab360Client(Q, T, http_client=http_client)
|
|
179
|
+
await client.authenticate()
|
|
180
|
+
result = await client.get_network_info(
|
|
181
|
+
"synthetic-device",
|
|
182
|
+
timeout=7.5,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
assert result == expected
|
|
186
|
+
assert events == ["push-connected", "http-requested", "push-closed"]
|
|
187
|
+
|
|
188
|
+
asyncio.run(scenario())
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_wait_for_network_info_timeout_is_api_error() -> None:
|
|
192
|
+
async def scenario() -> None:
|
|
193
|
+
from botslab360.protocol import PushClient
|
|
194
|
+
|
|
195
|
+
class FakePush(PushClient):
|
|
196
|
+
def __init__(self) -> None:
|
|
197
|
+
self._reader = object()
|
|
198
|
+
self._writer = object()
|
|
199
|
+
|
|
200
|
+
async def read_event(self):
|
|
201
|
+
await asyncio.Future()
|
|
202
|
+
|
|
203
|
+
with pytest.raises(ApiError) as raised:
|
|
204
|
+
await FakePush().wait_for_network_info(
|
|
205
|
+
device_id="synthetic-device",
|
|
206
|
+
task_id="synthetic-task",
|
|
207
|
+
timeout=0.001,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
assert raised.value.phase == "timeout"
|
|
211
|
+
|
|
212
|
+
asyncio.run(scenario())
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import asyncio
|
|
5
|
+
import importlib.util
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from botslab360 import AuthBackend, Device, DeviceIdentity, NetworkInfo
|
|
12
|
+
|
|
13
|
+
IDENTITY = DeviceIdentity(
|
|
14
|
+
mid="0123456789abcdef0123456789abcdef",
|
|
15
|
+
android_id="0123456789abcdef",
|
|
16
|
+
m2="12345678-1234-5678-9234-567812345678",
|
|
17
|
+
)
|
|
18
|
+
DEVICE = Device("synthetic-device", "Upstairs", "360 S9-P", True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _script_path() -> Path:
|
|
22
|
+
return Path(__file__).parents[1] / "diagnostics" / "test_network_info.py"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_diagnostic() -> ModuleType:
|
|
26
|
+
spec = importlib.util.spec_from_file_location(
|
|
27
|
+
"network_info_diagnostic",
|
|
28
|
+
_script_path(),
|
|
29
|
+
)
|
|
30
|
+
assert spec is not None and spec.loader is not None
|
|
31
|
+
module = importlib.util.module_from_spec(spec)
|
|
32
|
+
spec.loader.exec_module(module)
|
|
33
|
+
return module
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_script_uses_only_public_botslab360_api() -> None:
|
|
37
|
+
tree = ast.parse(_script_path().read_text(encoding="utf-8"))
|
|
38
|
+
botslab_imports = [
|
|
39
|
+
node
|
|
40
|
+
for node in ast.walk(tree)
|
|
41
|
+
if isinstance(node, ast.ImportFrom)
|
|
42
|
+
and node.module is not None
|
|
43
|
+
and node.module.startswith("botslab360")
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
assert len(botslab_imports) == 1
|
|
47
|
+
assert botslab_imports[0].module == "botslab360"
|
|
48
|
+
assert {alias.name for alias in botslab_imports[0].names} == {
|
|
49
|
+
"ApiError",
|
|
50
|
+
"AuthBackend",
|
|
51
|
+
"Botslab360Client",
|
|
52
|
+
"CaptchaRequired",
|
|
53
|
+
"Device",
|
|
54
|
+
"DeviceIdentity",
|
|
55
|
+
"NetworkInfo",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_live_flow_uses_public_api_and_redacts_sensitive_values(
|
|
60
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
61
|
+
capsys: pytest.CaptureFixture[str],
|
|
62
|
+
) -> None:
|
|
63
|
+
module = _load_diagnostic()
|
|
64
|
+
calls: list[object] = []
|
|
65
|
+
|
|
66
|
+
class FakeClient:
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_credentials(cls, **kwargs):
|
|
69
|
+
calls.append(("factory", kwargs))
|
|
70
|
+
return cls()
|
|
71
|
+
|
|
72
|
+
async def __aenter__(self):
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
async def __aexit__(self, *args):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
async def authenticate(self):
|
|
79
|
+
calls.append("authenticate")
|
|
80
|
+
|
|
81
|
+
async def get_devices(self):
|
|
82
|
+
calls.append("get_devices")
|
|
83
|
+
return [Device("other", "Other", "X90", True), DEVICE]
|
|
84
|
+
|
|
85
|
+
async def get_network_info(self, device, *, timeout):
|
|
86
|
+
calls.append(("get_network_info", device, timeout))
|
|
87
|
+
return NetworkInfo(
|
|
88
|
+
station_ip="192.168.4.20",
|
|
89
|
+
station_mac="aa:bb:cc:dd:ee:ff",
|
|
90
|
+
station_ssid="Private Home",
|
|
91
|
+
station_signal=-48,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
monkeypatch.setattr(module, "Botslab360Client", FakeClient)
|
|
95
|
+
|
|
96
|
+
result = asyncio.run(
|
|
97
|
+
module.run_network_info_test(
|
|
98
|
+
"private-account",
|
|
99
|
+
"private-password",
|
|
100
|
+
IDENTITY,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
assert result == 0
|
|
105
|
+
assert calls == [
|
|
106
|
+
(
|
|
107
|
+
"factory",
|
|
108
|
+
{
|
|
109
|
+
"email": "private-account",
|
|
110
|
+
"password": "private-password",
|
|
111
|
+
"backend": AuthBackend.ROBOT360,
|
|
112
|
+
"device_identity": IDENTITY,
|
|
113
|
+
},
|
|
114
|
+
),
|
|
115
|
+
"authenticate",
|
|
116
|
+
"get_devices",
|
|
117
|
+
("get_network_info", DEVICE, 30.0),
|
|
118
|
+
]
|
|
119
|
+
output = capsys.readouterr().out
|
|
120
|
+
assert "Device: 360 S9-P" in output
|
|
121
|
+
assert "Station IP: 192.168.4.20" in output
|
|
122
|
+
assert "Station MAC: aa:bb:cc:dd:ee:ff" in output
|
|
123
|
+
assert "OUI: aa:bb:cc" in output
|
|
124
|
+
assert "SSID: present (redacted)" in output
|
|
125
|
+
assert "NETWORK INFO DIAGNOSTIC STATUS: PASS" in output
|
|
126
|
+
for secret in (
|
|
127
|
+
"private-account",
|
|
128
|
+
"private-password",
|
|
129
|
+
"Private Home",
|
|
130
|
+
):
|
|
131
|
+
assert secret not in output
|
|
@@ -24,7 +24,17 @@ IDENTITY = DeviceIdentity(
|
|
|
24
24
|
m2="12345678-1234-5678-9234-567812345678",
|
|
25
25
|
)
|
|
26
26
|
DEVICE = Device("synthetic-device", "Synthetic Robot", "test", True)
|
|
27
|
-
ROOMS = [
|
|
27
|
+
ROOMS = [
|
|
28
|
+
Room(
|
|
29
|
+
1,
|
|
30
|
+
"Bad",
|
|
31
|
+
"bathroom",
|
|
32
|
+
clean_times=2,
|
|
33
|
+
fan_mode="max",
|
|
34
|
+
water_pump=1,
|
|
35
|
+
mode="mode_tiny_carpet",
|
|
36
|
+
)
|
|
37
|
+
]
|
|
28
38
|
|
|
29
39
|
|
|
30
40
|
def _script_path() -> Path:
|
|
@@ -133,6 +143,11 @@ def test_public_room_flow_uses_only_public_operations(
|
|
|
133
143
|
]
|
|
134
144
|
output = capsys.readouterr().out
|
|
135
145
|
assert "Public get_rooms(): success" in output
|
|
146
|
+
assert (
|
|
147
|
+
"ID / Name / room_type / vendor_mode / clean_times / fan_mode / water_pump"
|
|
148
|
+
in output
|
|
149
|
+
)
|
|
150
|
+
assert "1 / Bad / bathroom / mode_tiny_carpet / 2 / max / 1" in output
|
|
136
151
|
assert "Public clean_rooms([1]): success" in output
|
|
137
152
|
assert "PUBLIC ROOM API LIVE STATUS: PASS" in output
|
|
138
153
|
for secret in (
|
|
@@ -145,6 +160,67 @@ def test_public_room_flow_uses_only_public_operations(
|
|
|
145
160
|
assert secret not in output
|
|
146
161
|
|
|
147
162
|
|
|
163
|
+
def test_rooms_only_prints_profiles_without_cleaning(
|
|
164
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
165
|
+
capsys: pytest.CaptureFixture[str],
|
|
166
|
+
) -> None:
|
|
167
|
+
module = _load_diagnostic()
|
|
168
|
+
calls: list[object] = []
|
|
169
|
+
|
|
170
|
+
class FakeClient:
|
|
171
|
+
session = SimpleNamespace()
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def from_credentials(cls, **kwargs: object) -> FakeClient:
|
|
175
|
+
return cls()
|
|
176
|
+
|
|
177
|
+
async def __aenter__(self):
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
async def __aexit__(self, *args: object):
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
async def authenticate(self):
|
|
184
|
+
calls.append("authenticate")
|
|
185
|
+
|
|
186
|
+
async def get_devices(self):
|
|
187
|
+
calls.append("get_devices")
|
|
188
|
+
return [DEVICE]
|
|
189
|
+
|
|
190
|
+
async def get_rooms(self, device: Device):
|
|
191
|
+
calls.append(("get_rooms", device))
|
|
192
|
+
return ROOMS
|
|
193
|
+
|
|
194
|
+
async def clean_rooms(self, device: Device, room_ids: list[int]):
|
|
195
|
+
calls.append(("clean_rooms", device, room_ids))
|
|
196
|
+
|
|
197
|
+
monkeypatch.setattr(module, "Botslab360Client", FakeClient)
|
|
198
|
+
monkeypatch.setattr(
|
|
199
|
+
module,
|
|
200
|
+
"input",
|
|
201
|
+
lambda prompt: pytest.fail(f"Unexpected input prompt: {prompt}"),
|
|
202
|
+
raising=False,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
result = asyncio.run(
|
|
206
|
+
module.run_room_api_test(
|
|
207
|
+
"private-account",
|
|
208
|
+
"private-password",
|
|
209
|
+
IDENTITY,
|
|
210
|
+
rooms_only=True,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
assert result == 0
|
|
215
|
+
assert calls == ["authenticate", "get_devices", ("get_rooms", DEVICE)]
|
|
216
|
+
output = capsys.readouterr().out
|
|
217
|
+
assert "1 / Bad / bathroom / mode_tiny_carpet / 2 / max / 1" in output
|
|
218
|
+
assert "Room cleaning: not requested (--rooms-only)" in output
|
|
219
|
+
assert "PUBLIC ROOM PROFILE DIAGNOSTIC STATUS: PASS" in output
|
|
220
|
+
assert "private-account" not in output
|
|
221
|
+
assert "private-password" not in output
|
|
222
|
+
|
|
223
|
+
|
|
148
224
|
def test_cleaning_requires_exact_confirmation(
|
|
149
225
|
monkeypatch: pytest.MonkeyPatch,
|
|
150
226
|
) -> None:
|
|
@@ -57,7 +57,7 @@ def smart_area() -> dict:
|
|
|
57
57
|
"forbidType": "none",
|
|
58
58
|
"id": room_id,
|
|
59
59
|
"material": 0,
|
|
60
|
-
"mode":
|
|
60
|
+
"mode": "mode_tiny_carpet",
|
|
61
61
|
"name": encoded_name(f"Room {room_id}"),
|
|
62
62
|
"radius": 0,
|
|
63
63
|
"relativeRoom": -1,
|
|
@@ -144,7 +144,7 @@ def test_map_info_parses_ten_public_rooms() -> None:
|
|
|
144
144
|
clean_times=2,
|
|
145
145
|
fan_mode="max",
|
|
146
146
|
water_pump=2,
|
|
147
|
-
mode=
|
|
147
|
+
mode="mode_tiny_carpet",
|
|
148
148
|
)
|
|
149
149
|
assert parsed.rooms[9].room_type is None
|
|
150
150
|
assert parsed.map_id == 1
|
|
@@ -218,17 +218,24 @@ def test_room_model_remains_compatible_without_vertices() -> None:
|
|
|
218
218
|
assert room.mode is None
|
|
219
219
|
|
|
220
220
|
|
|
221
|
-
@pytest.mark.parametrize("mode", [
|
|
222
|
-
def
|
|
221
|
+
@pytest.mark.parametrize("mode", ["mode_big_carpet", "mode_tiny_carpet"])
|
|
222
|
+
def test_room_mode_parsing_preserves_vendor_strings(mode: str) -> None:
|
|
223
223
|
payload = map_info()
|
|
224
224
|
payload["smartArea"]["value"][1]["mode"] = mode
|
|
225
225
|
|
|
226
226
|
assert parse_room_map(payload).rooms[1].mode == mode
|
|
227
227
|
|
|
228
228
|
|
|
229
|
-
def
|
|
229
|
+
def test_room_mode_parsing_does_not_infer_numeric_cleaning_semantics() -> None:
|
|
230
230
|
payload = map_info()
|
|
231
|
-
payload["smartArea"]["value"][1]["mode"] =
|
|
231
|
+
payload["smartArea"]["value"][1]["mode"] = 2
|
|
232
|
+
|
|
233
|
+
assert parse_room_map(payload).rooms[1].mode is None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_missing_room_mode_remains_none() -> None:
|
|
237
|
+
payload = map_info()
|
|
238
|
+
payload["smartArea"]["value"][1].pop("mode")
|
|
232
239
|
|
|
233
240
|
assert parse_room_map(payload).rooms[1].mode is None
|
|
234
241
|
|
|
@@ -242,19 +249,34 @@ def test_public_room_setting_choices_match_android_values() -> None:
|
|
|
242
249
|
"max",
|
|
243
250
|
]
|
|
244
251
|
assert [level.value for level in RoomWaterLevel] == [1, 2, 3]
|
|
252
|
+
# Retained only so imports written against 0.4.2 keep working.
|
|
245
253
|
assert [mode.value for mode in RoomCleaningMode] == [1, 2, 3]
|
|
246
254
|
|
|
247
255
|
|
|
248
256
|
@pytest.mark.parametrize("mode", [1, 2, 3])
|
|
249
|
-
def
|
|
257
|
+
def test_prepare_area_setting_rejects_unverified_cleaning_modes(mode: int) -> None:
|
|
250
258
|
room_map = parse_room_map(map_info())
|
|
251
259
|
|
|
252
|
-
|
|
260
|
+
with pytest.raises(ValueError, match="unsupported"):
|
|
253
261
|
prepare_area_setting(room_map, [1], {1: RoomCleaningSettings(mode=mode)})
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_prepare_area_setting_preserves_vendor_mode_without_injecting_it() -> None:
|
|
265
|
+
payload = map_info()
|
|
266
|
+
payload["smartArea"]["value"][1]["mode"] = "mode_big_carpet"
|
|
267
|
+
payload["smartArea"]["value"][2].pop("mode")
|
|
268
|
+
room_map = parse_room_map(payload)
|
|
269
|
+
|
|
270
|
+
result = json.loads(
|
|
271
|
+
prepare_area_setting(
|
|
272
|
+
room_map,
|
|
273
|
+
[1, 2],
|
|
274
|
+
{1: RoomCleaningSettings(clean_times=1)},
|
|
275
|
+
)
|
|
254
276
|
)
|
|
255
277
|
|
|
256
|
-
assert result["value"][1]["mode"] ==
|
|
257
|
-
assert
|
|
278
|
+
assert result["value"][1]["mode"] == "mode_big_carpet"
|
|
279
|
+
assert "mode" not in result["value"][2]
|
|
258
280
|
|
|
259
281
|
|
|
260
282
|
@pytest.mark.parametrize(
|
|
@@ -263,19 +285,16 @@ def test_prepare_area_setting_accepts_supported_cleaning_modes(mode: int) -> Non
|
|
|
263
285
|
(RoomCleaningSettings(clean_times=1), {"cleanTimes": 1}),
|
|
264
286
|
(RoomCleaningSettings(fan_mode="quiet"), {"windMode": "quiet"}),
|
|
265
287
|
(RoomCleaningSettings(water_pump=3), {"waterPump": 3}),
|
|
266
|
-
(RoomCleaningSettings(mode=2), {"mode": 2}),
|
|
267
288
|
(
|
|
268
289
|
RoomCleaningSettings(
|
|
269
290
|
clean_times=1,
|
|
270
291
|
fan_mode=RoomFanMode.AUTO,
|
|
271
292
|
water_pump=RoomWaterLevel.LOW,
|
|
272
|
-
mode=RoomCleaningMode.SWEEP_AND_MOP,
|
|
273
293
|
),
|
|
274
294
|
{
|
|
275
295
|
"cleanTimes": 1,
|
|
276
296
|
"windMode": "auto",
|
|
277
297
|
"waterPump": 1,
|
|
278
|
-
"mode": 1,
|
|
279
298
|
},
|
|
280
299
|
),
|
|
281
300
|
],
|
|
@@ -338,6 +357,9 @@ def test_prepare_area_setting_supports_different_selected_room_settings() -> Non
|
|
|
338
357
|
(RoomCleaningSettings(water_pump=4), ValueError),
|
|
339
358
|
(RoomCleaningSettings(water_pump=True), TypeError),
|
|
340
359
|
(RoomCleaningSettings(water_pump="1"), TypeError), # type: ignore[arg-type]
|
|
360
|
+
(RoomCleaningSettings(mode=1), ValueError),
|
|
361
|
+
(RoomCleaningSettings(mode=2), ValueError),
|
|
362
|
+
(RoomCleaningSettings(mode=3), ValueError),
|
|
341
363
|
(RoomCleaningSettings(mode=0), ValueError),
|
|
342
364
|
(RoomCleaningSettings(mode=4), ValueError),
|
|
343
365
|
(RoomCleaningSettings(mode=True), TypeError),
|
|
@@ -519,15 +541,13 @@ def test_clean_rooms_sends_per_room_overrides_through_public_api(
|
|
|
519
541
|
(RoomCleaningSettings(clean_times=1), {"cleanTimes": 1}),
|
|
520
542
|
(RoomCleaningSettings(fan_mode="quiet"), {"windMode": "quiet"}),
|
|
521
543
|
(RoomCleaningSettings(water_pump=3), {"waterPump": 3}),
|
|
522
|
-
(RoomCleaningSettings(mode=2), {"mode": 2}),
|
|
523
544
|
(
|
|
524
545
|
RoomCleaningSettings(
|
|
525
546
|
clean_times=1,
|
|
526
547
|
fan_mode="strong",
|
|
527
548
|
water_pump=3,
|
|
528
|
-
mode=3,
|
|
529
549
|
),
|
|
530
|
-
{"cleanTimes": 1, "windMode": "strong", "waterPump": 3
|
|
550
|
+
{"cleanTimes": 1, "windMode": "strong", "waterPump": 3},
|
|
531
551
|
),
|
|
532
552
|
],
|
|
533
553
|
)
|
|
@@ -647,6 +667,9 @@ def test_clean_rooms_preserves_vendor_water_pump_zero(monkeypatch) -> None:
|
|
|
647
667
|
([1], {1: RoomCleaningSettings(water_pump=0)}, ValueError),
|
|
648
668
|
([1], {1: RoomCleaningSettings(water_pump=4)}, ValueError),
|
|
649
669
|
([1], {1: RoomCleaningSettings(water_pump=True)}, TypeError),
|
|
670
|
+
([1], {1: RoomCleaningSettings(mode=1)}, ValueError),
|
|
671
|
+
([1], {1: RoomCleaningSettings(mode=2)}, ValueError),
|
|
672
|
+
([1], {1: RoomCleaningSettings(mode=3)}, ValueError),
|
|
650
673
|
([1], {1: RoomCleaningSettings(mode=0)}, ValueError),
|
|
651
674
|
([1], {1: RoomCleaningSettings(mode=4)}, ValueError),
|
|
652
675
|
([1], {1: RoomCleaningSettings(mode=True)}, TypeError),
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|