botslab360 0.4.2__tar.gz → 0.6.0__tar.gz

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