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.
Files changed (35) hide show
  1. {botslab360-0.4.2/src/botslab360.egg-info → botslab360-0.5.0}/PKG-INFO +33 -11
  2. {botslab360-0.4.2 → botslab360-0.5.0}/README.md +32 -10
  3. {botslab360-0.4.2 → botslab360-0.5.0}/pyproject.toml +1 -1
  4. {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/__init__.py +2 -0
  5. {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/client.py +56 -0
  6. {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/models.py +26 -3
  7. {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/protocol.py +120 -1
  8. {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/rooms.py +5 -7
  9. {botslab360-0.4.2 → botslab360-0.5.0/src/botslab360.egg-info}/PKG-INFO +33 -11
  10. {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360.egg-info/SOURCES.txt +2 -0
  11. botslab360-0.5.0/tests/test_network_info.py +212 -0
  12. botslab360-0.5.0/tests/test_network_info_diagnostic.py +131 -0
  13. {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_public_room_api_diagnostic.py +77 -1
  14. {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_rooms.py +39 -16
  15. {botslab360-0.4.2 → botslab360-0.5.0}/ATTRIBUTION.md +0 -0
  16. {botslab360-0.4.2 → botslab360-0.5.0}/LICENSE +0 -0
  17. {botslab360-0.4.2 → botslab360-0.5.0}/setup.cfg +0 -0
  18. {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/auth.py +0 -0
  19. {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/commands.py +0 -0
  20. {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/exceptions.py +0 -0
  21. {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360/quc.py +0 -0
  22. {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360.egg-info/dependency_links.txt +0 -0
  23. {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360.egg-info/requires.txt +0 -0
  24. {botslab360-0.4.2 → botslab360-0.5.0}/src/botslab360.egg-info/top_level.txt +0 -0
  25. {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_android_360_diagnostic.py +0 -0
  26. {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_auth.py +0 -0
  27. {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_commands.py +0 -0
  28. {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_compare_android_quc.py +0 -0
  29. {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_credentials_auth_example.py +0 -0
  30. {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_devices.py +0 -0
  31. {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_protocol.py +0 -0
  32. {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_public_robot360_auth_diagnostic.py +0 -0
  33. {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_quc.py +0 -0
  34. {botslab360-0.4.2 → botslab360-0.5.0}/tests/test_room_diagnostic.py +0 -0
  35. {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.4.2
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
- > Version `0.4.1` should be considered experimental.
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, and three cleaning modes. Optional settings
221
- can override the current attributes for selected rooms in one cleaning request:
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
- Cleaning modes are `1` (sweep and mop), `2` (sweep), and `3` (mop). Mode `2`
254
- has been verified on a real robot; the assignments for modes `1` and `3` remain
255
- provisional pending real-device verification. `Room.mode` preserves unknown
256
- integer values reported by future vendor firmware, while outgoing overrides are
257
- limited to the currently supported values.
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
- > Version `0.4.1` should be considered experimental.
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, and three cleaning modes. Optional settings
201
- can override the current attributes for selected rooms in one cleaning request:
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
- Cleaning modes are `1` (sweep and mop), `2` (sweep), and `3` (mop). Mode `2`
234
- has been verified on a real robot; the assignments for modes `1` and `3` remain
235
- provisional pending real-device verification. `Room.mode` preserves unknown
236
- integer values reported by future vendor firmware, while outgoing overrides are
237
- limited to the currently supported values.
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "botslab360"
7
- version = "0.4.2"
7
+ version = "0.5.0"
8
8
  description = "Async Python client for Botslab/360 robot vacuums"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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: int | None = None
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
- """Cleaning modes supported by the Android room-attribute UI."""
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
- if settings.mode not in _ROOM_CLEANING_MODE_VALUES:
108
- raise ValueError("mode must be 1, 2, or 3")
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=_optional_int(area.get("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.4.2
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
- > Version `0.4.1` should be considered experimental.
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, and three cleaning modes. Optional settings
221
- can override the current attributes for selected rooms in one cleaning request:
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
- Cleaning modes are `1` (sweep and mop), `2` (sweep), and `3` (mop). Mode `2`
254
- has been verified on a real robot; the assignments for modes `1` and `3` remain
255
- provisional pending real-device verification. `Room.mode` preserves unknown
256
- integer values reported by future vendor firmware, while outgoing overrides are
257
- limited to the currently supported values.
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 = [Room(1, "Bad", "bathroom", 1, "max", 2)]
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": 2,
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=2,
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", [1, 2, 3, 99])
222
- def test_room_mode_parsing_preserves_supported_and_unknown_values(mode: int) -> None:
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 test_room_mode_parsing_ignores_malformed_values() -> None:
229
+ def test_room_mode_parsing_does_not_infer_numeric_cleaning_semantics() -> None:
230
230
  payload = map_info()
231
- payload["smartArea"]["value"][1]["mode"] = "future-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 test_prepare_area_setting_accepts_supported_cleaning_modes(mode: int) -> None:
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
- result = json.loads(
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"] == mode
257
- assert result["value"][0] == room_map.area_setting["value"][0]
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, "mode": 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