python-roborock 5.24.0__py3-none-any.whl → 5.26.0__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 5.24.0
3
+ Version: 5.26.0
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Project-URL: Repository, https://github.com/python-roborock/python-roborock
6
6
  Project-URL: Documentation, https://python-roborock.readthedocs.io/
@@ -8,24 +8,26 @@ roborock/diagnostics.py,sha256=YIc7BhC6Cy6W2a6hykvfSRPG8NpLrPZCc_7jTX2Zdho,5091
8
8
  roborock/exceptions.py,sha256=t2YvfSRMymZPtdPV238itjxVJTaTq5-uBL_EM5FTlfg,3159
9
9
  roborock/protocol.py,sha256=eNUL6UGmtSdfsGM1mlYjmorukGGudV45iKXUA7a-Qa8,20193
10
10
  roborock/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- roborock/roborock_message.py,sha256=BPDT_uzCeD4L_4LottCMeft1kYmYPoG9ktcqqj0SYhQ,6466
11
+ roborock/roborock_message.py,sha256=e7gDnBhv2RkG3G2eXmoNVnt5aUWWlLEA3i5LTIBFBy8,7349
12
12
  roborock/roborock_typing.py,sha256=Rwx0c0oZCMY-4O8VMLnFSO_OUNb_D59cyG_ktZTZoZI,17134
13
13
  roborock/util.py,sha256=mjw329ujLjoWPPAr9mG0VZstaVL06dmVvNnYACB-Pxk,1650
14
14
  roborock/web_api.py,sha256=M_A4PiRK5cwkoT3oS8fvglOU97s1jpWKuuCxeeCiF_E,37507
15
- roborock/data/__init__.py,sha256=DikqaVM7VPVddkBNkHSss5zb1_iKB7GYmc2GiHoMAkA,257
15
+ roborock/data/__init__.py,sha256=jSZHOrd-LfVN-TdBkjNW334ef99rGBz4-hPT_7KvoLs,278
16
16
  roborock/data/code_mappings.py,sha256=Cj1nEQkv3Vko7Mh8FODNtfIS5gn5KlTq2QXFtcincHo,8948
17
17
  roborock/data/containers.py,sha256=-b6H2snGQTzsJSsctgil6sx8rLtCDihTTzdkxcFRdlE,16489
18
18
  roborock/data/b01_q10/__init__.py,sha256=E4lwJcz4AI1z92FcYOJ-cY3fcfHSoXqvYVgfA-TgaRk,71
19
- roborock/data/b01_q10/b01_q10_code_mappings.py,sha256=aIKSHYSWD3DNjifpRB8aywwxYWNP4d6n6N0weI7SbsU,11218
20
- roborock/data/b01_q10/b01_q10_containers.py,sha256=RhujRDmojBeHEVUZgt4xzFLD3QfPXN2PPKhA7J6pbXE,10937
19
+ roborock/data/b01_q10/b01_q10_code_mappings.py,sha256=UBFkxCWLYCGQatENmNwR0owkXrcB8AosnD-hICfS2AY,15203
20
+ roborock/data/b01_q10/b01_q10_containers.py,sha256=jieHxHeWIRm2NHVdv15J6kYSeHjAZunGrvZsob-RIH8,11130
21
21
  roborock/data/b01_q7/__init__.py,sha256=D-Cd8eB0njf1PQPM6Exl_9A0AhpzIjrL0hapGOCEkkA,69
22
22
  roborock/data/b01_q7/b01_q7_code_mappings.py,sha256=EHSCZSv_Os049NcenKo4tkPjDOB4vpmQ2p2Psr9FRCc,10698
23
23
  roborock/data/b01_q7/b01_q7_containers.py,sha256=jNEHoNEdq276XJZGGUmtSyJ81cSIzJwem-O_NOgmfdQ,10270
24
24
  roborock/data/dyad/__init__.py,sha256=Ksx7Ri1alsAYAyILOpj9iNbCC_6sF7xvETFRnTO4rdM,65
25
25
  roborock/data/dyad/dyad_code_mappings.py,sha256=MVhAuQaI2TcDBMQKQcbRk4MbFObx_5XEXTc3n4uAeWc,2836
26
26
  roborock/data/dyad/dyad_containers.py,sha256=klJD2wV-2McjAoSN8Bthuyeid59L7M8nzSvc3MtmqOI,434
27
+ roborock/data/mower/__init__.py,sha256=v-bMapgWFzlb0jJVJBmsnmDfgnER7oTjP0OH3ikwGdE,79
28
+ roborock/data/mower/mower_containers.py,sha256=mCsRFTE2AZ2wC9wzrzsr-3z7T2M63CH5ax9kPqPygIY,3194
27
29
  roborock/data/v1/__init__.py,sha256=Lx_D6baZrCMxp57D0NcdiA8rssJ5NnInZwUwrT79XOI,91
28
- roborock/data/v1/v1_clean_modes.py,sha256=pJBwykCToEKZv9zs11x45Jjq3giUKfp0zibAjQ_LuOQ,7194
30
+ roborock/data/v1/v1_clean_modes.py,sha256=71_olE6rsdx_UwxkBEPlcHTz0vnyKSnURGxIwuqhOwM,14056
29
31
  roborock/data/v1/v1_code_mappings.py,sha256=EgkR3mexyNnPoHZSZHgxA0-ClLDCOFtPIlyEUrWfDIE,17448
30
32
  roborock/data/v1/v1_containers.py,sha256=SW7NhY5zbu9j7gG9qEUwpfzMpRn_4RQHOtgnbeMRX5Y,27936
31
33
  roborock/data/zeo/__init__.py,sha256=VxG5qG4M24DGOq2YAaxownWp6bEfj9DEOgOMHmTpcqY,63
@@ -35,23 +37,23 @@ roborock/devices/README.md,sha256=zjssyRIlFWTgQz8hLdsJC9_stlHdUQybz7r2af_8q1k,21
35
37
  roborock/devices/__init__.py,sha256=yOWuncnp5wwKPLzcnblsBMGD0v8hcDDDC_x7YdSrN70,129
36
38
  roborock/devices/cache.py,sha256=V3KC16LzTEAp_cdAirNtWPjZAgz6ugn3zfsYPyrbWsE,4633
37
39
  roborock/devices/device.py,sha256=8lHdjLlHhrENPjrZxllpYU0CEh7Os9rp9spnwhdUOoc,9678
38
- roborock/devices/device_manager.py,sha256=u9Z4INuTxOILJnLFx-rcHKH1Z4C2AH-z2wEe9usLnjM,11190
40
+ roborock/devices/device_manager.py,sha256=NRQK9przB_FYFsljf7fz9_fL1NJCQSGzcvXchP-kplc,11731
39
41
  roborock/devices/file_cache.py,sha256=-G2Bm4ra8K_DvSBjntv5hGaRDB-VzSoKWcI0Y_kK6jw,2724
40
42
  roborock/devices/rpc/__init__.py,sha256=MDtZovvHi3F4Wbgyzu6gyjAjkZ6xYGU1LRQmtiIapuU,561
41
43
  roborock/devices/rpc/a01_channel.py,sha256=9Cf3AlTTAHbts7sodX4gAXAGOIf5CA6WtqykZcYAfnk,3350
42
- roborock/devices/rpc/b01_q10_channel.py,sha256=stXKvkN3t0m0Reosu54CbVDuMwhrZIOc-pL4ZJ7IAxk,1929
43
- roborock/devices/rpc/b01_q7_channel.py,sha256=Dz6WnAKTzXS21auv17CDHmJrkLWzHoXE1uAgAXcQsVo,6163
44
+ roborock/devices/rpc/b01_q10_channel.py,sha256=nT7fsTtti37alm68CHzId8Wy3GMd6npiSNB8Gjegy3I,3494
45
+ roborock/devices/rpc/b01_q7_channel.py,sha256=cxJR_DjJobP6SsAch_O3tNcJdiQGMIwpxH8Wx8bb9xA,7689
44
46
  roborock/devices/rpc/v1_channel.py,sha256=aYlBZ0zC7jbwJhhHvKzBfHUZLAf7KOmIPOJQiiLKwvE,22275
45
47
  roborock/devices/traits/__init__.py,sha256=YaL9qoxG6IBG0XXDP0HMTvoc0Jkmq7AF6iH2oYWNtcg,765
46
48
  roborock/devices/traits/common.py,sha256=KUlXvx2UcFVeNXEhPiCAdy2APu7zrJmM_jME_bM8Odg,4474
47
49
  roborock/devices/traits/traits_mixin.py,sha256=7t15l_Ty1cN-3-fKNtRH1XR6-mfadPnTBCQRZP4rAtc,2092
48
50
  roborock/devices/traits/a01/__init__.py,sha256=i-us9zLPWPfSQ5XWq8t0MiYLYe5NkcLvk3aEQTYT7E0,8104
49
51
  roborock/devices/traits/b01/__init__.py,sha256=pGN6wEocE6Fr2BTasG8Z_75crD6OdTwpju59Vw7eTDk,202
50
- roborock/devices/traits/b01/q10/__init__.py,sha256=0-vLn_ltHyE2aABuCpgmW8Yr87RI7wHoi1m4e90befE,6086
52
+ roborock/devices/traits/b01/q10/__init__.py,sha256=wvXErH_V6L6bW-EYQFw1PfC8LouKpwItVSsTx2TYMJ4,6010
51
53
  roborock/devices/traits/b01/q10/button_light.py,sha256=_CCWSjFe_iZnnSXgBHOju31VUmwG1KKd_jGkmSpLew4,994
52
54
  roborock/devices/traits/b01/q10/child_lock.py,sha256=bTsbAMxn7AF1zpWteyZCkQm4jj3NiWD8ddZTM3tlG_0,1131
53
55
  roborock/devices/traits/b01/q10/clean_history.py,sha256=OsuzliXtWl2RDMAdKElCaebp9Pr2czVOex180k4jzVs,6672
54
- roborock/devices/traits/b01/q10/command.py,sha256=mcWNRSq72bfa79_EjKRgMEIDTV3vWFh2ofBe4aF7EOE,1306
56
+ roborock/devices/traits/b01/q10/command.py,sha256=M0sZu1eIvKYfsOpmJc-H4LKsSrF-kTp_vc_jCRBAvaY,1264
55
57
  roborock/devices/traits/b01/q10/common.py,sha256=Qxl3zBAUulqdrjFZJ0DPEFEIJLvlPqUKuTQGPloy7Ds,3089
56
58
  roborock/devices/traits/b01/q10/consumable.py,sha256=MbtMgYKqzNdVn6M1iyOAqZPCYTbN_Q_Pr4h8EYGEiuY,669
57
59
  roborock/devices/traits/b01/q10/do_not_disturb.py,sha256=klyGQZ_nHyO6bhPlwBl8vElTnMwPf4A8VXwxkU8rMlM,1157
@@ -62,10 +64,10 @@ roborock/devices/traits/b01/q10/remote.py,sha256=8-fg4SA5M-tdMufHPeD9hIcax7y9Qh7
62
64
  roborock/devices/traits/b01/q10/status.py,sha256=JAPixbsQ8jfN3OvI7o_NxISXFk8l7rGHFYcL4pH97Jk,859
63
65
  roborock/devices/traits/b01/q10/vacuum.py,sha256=w2FftNH5n-Tb-9drR3-5OiMy85FJfTDNmtt34LklC8w,4374
64
66
  roborock/devices/traits/b01/q10/volume.py,sha256=UnT0yYDQntxYwnpv4_NVychm0FnGAYRAWA_FJR4yJag,1001
65
- roborock/devices/traits/b01/q7/__init__.py,sha256=gHgIK-KBLDJDEneBmSJfTa38sumj1IuZl-lcvoZeYLY,8116
66
- roborock/devices/traits/b01/q7/clean_summary.py,sha256=Tod29LUHBsOS0XL8SU1SdBQEHWTCifGZVCwSWGhYpy8,2933
67
- roborock/devices/traits/b01/q7/map.py,sha256=Zrvj8LNctIFksCBoiZVQ1x4hgG3wwp1jqPY-If__JXw,1489
68
- roborock/devices/traits/b01/q7/map_content.py,sha256=rI21-kMmIgf1TY98HXrWG_4rEEs9qzdYBm7RTU47yN4,3519
67
+ roborock/devices/traits/b01/q7/__init__.py,sha256=y3NV-DYHyQoPcpWnmGppX5Y1GjF9MylVxyoIVldKFCA,7935
68
+ roborock/devices/traits/b01/q7/clean_summary.py,sha256=M43iJVeYjrkBV6ZxXdHtn4Rd5Mx-KAK7WLU5fABiN8I,2742
69
+ roborock/devices/traits/b01/q7/map.py,sha256=NfPQAEwNCUsb0SYGMxmGeq4q4rEHqDJK0zTD5cE-ESs,1299
70
+ roborock/devices/traits/b01/q7/map_content.py,sha256=5l7WJSDEfUzXkwXYcvTWhuNz3mlXvA_u2o5Pnh6f5kE,3372
69
71
  roborock/devices/traits/v1/__init__.py,sha256=yUa3oN5CZtuVh-fst8FlstpCy1XnS3q42oPlQG9fFYw,15616
70
72
  roborock/devices/traits/v1/child_lock.py,sha256=NeaPqcEoBRXvEzKZGonkyINkwCBOK3xa4zSWvJW66kA,1161
71
73
  roborock/devices/traits/v1/clean_summary.py,sha256=2uDl3Tg6H9INf7HTEPPat_PBgodoWeX_ko5MJgWggn8,4625
@@ -84,7 +86,7 @@ roborock/devices/traits/v1/network_info.py,sha256=CX9-ye2Zb_8lAWPGvegXWSOguxMY2K
84
86
  roborock/devices/traits/v1/rooms.py,sha256=PKNEvzL_Pk5pxGpiMC-XgGpgWVRjivMw_yv7vpGlz3s,6221
85
87
  roborock/devices/traits/v1/routines.py,sha256=BfktlpnyDjD2DB3zBDS5BnmjZPE38aHa-wjMontPnik,931
86
88
  roborock/devices/traits/v1/smart_wash_params.py,sha256=fAhJpMgUUP1kJcfEAAPeD3anMmCsuX7I1SEoUrUgfjE,504
87
- roborock/devices/traits/v1/status.py,sha256=g71kUSZ5XKMSF9mEH2QGr-d9L9ecyUGB1M-LKiKz-js,3915
89
+ roborock/devices/traits/v1/status.py,sha256=Y5VBJPZQhmlGDPQvPIp8mFCFsUf4hheZ5k_nnxOoXHI,5071
88
90
  roborock/devices/traits/v1/valley_electricity_timer.py,sha256=vE9bLJe8TdFGVh_9mDRkEoC3_DTBkDcdlSMSiYq0YTw,1863
89
91
  roborock/devices/traits/v1/volume.py,sha256=etRIYE0ynZZLEo2ez7Dm7_JbijmElrdjpn59hyLWqsQ,814
90
92
  roborock/devices/traits/v1/wash_towel_mode.py,sha256=MpVIGYVBC-WNSSFy6PPN-1eeIgJm_XoEzMSTEp9R29g,1913
@@ -110,8 +112,8 @@ roborock/protocols/a01_protocol.py,sha256=JCIhUuVNamcENu0gtZR2x8rBz6dpAZLcbA-NQg
110
112
  roborock/protocols/b01_q10_protocol.py,sha256=Ac3_VHJ2DAwBiXFMPYOHExEV0prxS9Ju0djiC8nKtiI,4935
111
113
  roborock/protocols/b01_q7_protocol.py,sha256=o8repRrxhxknzspsg1K27ds643rs87ilWXbj8RJtOCs,4619
112
114
  roborock/protocols/v1_protocol.py,sha256=uA-EMXd1AUBAXjkjAm0VxZlPLre0v2X9Kei5UqyPzaY,11141
113
- python_roborock-5.24.0.dist-info/METADATA,sha256=nYXBxUJ0Sf16pHOh1T0wbZrPD39O6U-gJi4YVJtR-M4,5281
114
- python_roborock-5.24.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
115
- python_roborock-5.24.0.dist-info/entry_points.txt,sha256=EvC1nMqi9ZXKgZnqlNXA33v_3nzgPNjnM0mzWlrehnY,47
116
- python_roborock-5.24.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
117
- python_roborock-5.24.0.dist-info/RECORD,,
115
+ python_roborock-5.26.0.dist-info/METADATA,sha256=S6gc78zARVWGikbdJL_rBkkWOMFx-q5COvsyzCSjblc,5281
116
+ python_roborock-5.26.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
117
+ python_roborock-5.26.0.dist-info/entry_points.txt,sha256=EvC1nMqi9ZXKgZnqlNXA33v_3nzgPNjnM0mzWlrehnY,47
118
+ python_roborock-5.26.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
119
+ python_roborock-5.26.0.dist-info/RECORD,,
roborock/data/__init__.py CHANGED
@@ -5,5 +5,6 @@ from .b01_q10 import *
5
5
  from .code_mappings import *
6
6
  from .containers import *
7
7
  from .dyad import *
8
+ from .mower import *
8
9
  from .v1 import *
9
10
  from .zeo import *
@@ -289,6 +289,70 @@ class YXCarpetCleanType(RoborockModeEnum):
289
289
  CROSS = "cross", 3
290
290
 
291
291
 
292
+ class YXFault(RoborockModeEnum):
293
+ """Q10 (B01/ss07) ``dpFault`` (90) codes, from the ss07 fault spec.
294
+
295
+ ``dpFault`` is *overloaded*: several values are lifecycle/status rather than
296
+ errors (e.g. 400 = scheduled clean starting, 501 = returning to dock,
297
+ 502 = recharge). A non-zero fault is not necessarily a blocking error.
298
+ The converse also holds: the device can sit in its error state (``dpStatus``
299
+ 12) with ``dpFault`` still 0 (observed live with the dust-bin module
300
+ removed), so a ``None``/``NONE`` fault does not imply the absence of an
301
+ error condition.
302
+
303
+ These labels differ from the Q7 ``B01Fault`` for several shared numbers
304
+ (500, 501, 503, 569, 570) -- so this is a Q10-specific map, not a reuse of
305
+ ``B01Fault``. Codes marked "hw-confirmed" were observed firing live on a
306
+ physical ss07 in a context matching the label; the rest are from the spec
307
+ only, not yet observed live (single device).
308
+ """
309
+
310
+ UNKNOWN = "unknown", -1
311
+ NONE = "none", 0
312
+ LIDAR_BLOCKED = "lidar_blocked", 1
313
+ BUMPER_STUCK = "bumper_stuck", 2 # hw-confirmed (bumper held in during commanded motion)
314
+ ROBOT_SUSPENDED = "robot_suspended", 3 # hw-confirmed (wheels lifted off the floor mid-task)
315
+ CLIFF_SENSOR_ERROR = "cliff_sensor_error", 4 # hw-confirmed (one side lifted mid-task)
316
+ MAIN_BRUSH_STUCK = "main_brush_stuck", 5 # hw-confirmed (two physical brush jams)
317
+ MAIN_WHEELS_STUCK = "main_wheels_stuck", 7
318
+ ROBOT_TRAPPED = "robot_trapped", 8 # hw-confirmed
319
+ CHECK_DUSTBIN_FILTER = "check_dustbin_filter", 9
320
+ LOW_BATTERY = "low_battery", 12 # hw-confirmed (fired at 14% mid clean)
321
+ TEMPERATURE_THRESHOLD = "temperature_threshold", 14
322
+ ROBOT_TILTED = "robot_tilted", 16
323
+ LIDAR_COVER_OBSTRUCTED = "lidar_cover_obstructed", 21
324
+ NO_GO_ZONE_DETECTED = "no_go_zone_detected", 24
325
+ MOPPING_MODULE_STUCK = "mopping_module_stuck", 27
326
+ CARPET_AVOIDANCE = "carpet_avoidance", 28
327
+ CANNOT_CROSS_CARPET = "cannot_cross_carpet", 29
328
+ INSTALL_DUST_BAG = "install_dust_bag", 46 # hw-confirmed (docked with bag removed; fires at dock contact)
329
+ MOP_MOUNT_FELL_OFF = "mop_mount_fell_off", 54
330
+ LIDAR_DIRTY = "lidar_dirty", 58
331
+ FILTER_SERVICE_LIFE = "filter_service_life", 301
332
+ MAIN_BRUSH_SERVICE_LIFE = "main_brush_service_life", 302
333
+ SIDE_BRUSH_SERVICE_LIFE = "side_brush_service_life", 303
334
+ SENSOR_NEEDS_CLEANING = "sensor_needs_cleaning", 304 # hw-confirmed (fired during auto-empty)
335
+ DUST_BAG_FULL = "dust_bag_full", 310 # inferred from auto-empty context; not hw-confirmed
336
+ STARTING_SCHEDULED_CLEAN = "starting_scheduled_clean", 400 # hw-confirmed x3; lifecycle, not an error
337
+ # hw-confirmed (a due scheduled clean fired mid-clean and was ignored); lifecycle, not an error
338
+ CLEANING_IN_PROGRESS = "cleaning_in_progress", 407
339
+ EMPTY_DUSTBIN = "empty_dustbin", 500 # ss07 != Q7 B01Fault (lidar_blocked); spec-only
340
+ # hw-confirmed, fires per completed task; ss07 != Q7 (robot_suspended)
341
+ CLEANING_COMPLETED_RETURNING = "cleaning_completed_returning", 501
342
+ LOW_BATTERY_RESUME = "low_battery_resume", 502 # hw-confirmed; lifecycle
343
+ DOCKING_ERROR = "docking_error", 503 # hw-confirmed; ss07 != Q7 (dustbin_not_installed)
344
+ POSITIONING_FAILED = "positioning_failed", 556 # hw-confirmed; relocalization
345
+ # hw-confirmed (3rd auto-empty in ~15 min: dock refuses to run the cycle); ss07 != Q7 (main_wheels_entangled)
346
+ TOO_FREQUENT_EMPTYING = "too_frequent_emptying", 569
347
+ CANNOT_REACH_TARGET = "cannot_reach_target", 570 # hw-confirmed; ss07 != Q7 (main_brush_entangled)
348
+ OFFLINE_WARNING_ASLEEP = "offline_warning_asleep", 588
349
+ OFFLINE_WARNING_LOW_BATTERY = "offline_warning_low_battery", 589
350
+ DND_AUTO_TOPUP_DISABLED = "dnd_auto_topup_disabled", 591
351
+ CLEAN_CARPET_ULTRASONIC_SENSORS = "clean_carpet_ultrasonic_sensors", 707
352
+ ROBOT_ERROR_RESET = "robot_error_reset", 1002
353
+ VOICE_PACK_UPDATE_AVAILABLE = "voice_pack_update_available", 3001
354
+
355
+
292
356
  class RemoteCommand(IntEnum):
293
357
  FORWARD = 0
294
358
  LEFT = 2
@@ -23,6 +23,7 @@ from .b01_q10_code_mappings import (
23
23
  YXDeviceDustCollectionFrequency,
24
24
  YXDeviceState,
25
25
  YXFanLevel,
26
+ YXFault,
26
27
  YXStartMethod,
27
28
  YXWaterLevel,
28
29
  )
@@ -165,7 +166,7 @@ class Q10Status(RoborockBase):
165
166
  clean_task_type: YXDeviceCleanTask | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_TASK_TYPE})
166
167
  back_type: YXBackType | None = field(default=None, metadata={"dps": B01_Q10_DP.BACK_TYPE})
167
168
  cleaning_progress: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_PROGRESS})
168
- fault: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FAULT})
169
+ fault: YXFault | None = field(default=None, metadata={"dps": B01_Q10_DP.FAULT})
169
170
 
170
171
  # Additional state reported in the device's full status dump.
171
172
  clean_line: YXCleanLine | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_LINE})
@@ -203,6 +204,11 @@ class Q10Status(RoborockBase):
203
204
  filter_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FILTER_LIFE})
204
205
  sensor_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SENSOR_LIFE})
205
206
 
207
+ @property
208
+ def fault_name(self) -> str | None:
209
+ """Returns the name of the current fault."""
210
+ return self.fault.value if self.fault is not None else None
211
+
206
212
 
207
213
  @dataclass
208
214
  class SoundVolume(RoborockBase):
@@ -0,0 +1,3 @@
1
+ """Dataclasses for Roborock mower devices."""
2
+
3
+ from .mower_containers import *
@@ -0,0 +1,43 @@
1
+ """Data containers for Roborock mower devices."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from roborock.data.containers import RoborockBase
7
+ from roborock.roborock_message import RoborockMowerDataProtocol
8
+
9
+
10
+ @dataclass
11
+ class MowerStatus(RoborockBase):
12
+ """Core mower status backed by mower DPS updates."""
13
+
14
+ error_code: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.ERROR_CODE})
15
+ battery: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.BATTERY})
16
+ mow_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_TYPE})
17
+ mow_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_STATE})
18
+ mapping_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MAPPING_TYPE})
19
+ mapping_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MAPPING_STATE})
20
+ ota_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.OTA_STATE})
21
+ charge_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.CHARGE_STATE})
22
+ dock_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.DOCK_STATE})
23
+ charge_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.CHARGE_TYPE})
24
+ pend_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.PEND_TYPE})
25
+ remote_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.REMOTE_STATE})
26
+ mow_start_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_START_TYPE})
27
+ mow_eff_mode: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_EFF_MODE})
28
+ mow_height: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_HEIGHT})
29
+ mow_direction_angle: int | None = field(
30
+ default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_DIRECTION_ANGLE}
31
+ )
32
+ mow_pattern: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_PATTERN})
33
+ mow_conf_mode: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_CONF_MODE})
34
+ offline_status: Any | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.OFFLINE_STATUS})
35
+ mow_progress: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_PROGRESS})
36
+ blade_lifespan: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.BLADE_LIFESPAN})
37
+ fc_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.FC_STATE})
38
+ gps_coordinate: Any | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.GPS_COORDINATE})
39
+ off_dock_no_task_status: int | None = field(
40
+ default=None, metadata={"dps": RoborockMowerDataProtocol.OFF_DOCK_NO_TASK_STATUS}
41
+ )
42
+ afs_status: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.AFS_STATUS})
43
+ network_channel: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.NETWORK_CHANNEL})
@@ -1,7 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import typing
4
+ from enum import StrEnum
5
+ from typing import TypeVar
4
6
 
7
+ from ...exceptions import RoborockUnsupportedFeature
5
8
  from ..code_mappings import RoborockModeEnum
6
9
 
7
10
  if typing.TYPE_CHECKING:
@@ -68,6 +71,22 @@ class WashTowelModes(RoborockModeEnum):
68
71
  SUPER_DEEP = ("super_deep", 8)
69
72
 
70
73
 
74
+ class CleaningMode(StrEnum):
75
+ """High-level cleaning intent derived from the lower-level motor settings.
76
+
77
+ Prefer this abstraction when you want to present or switch between the
78
+ user-facing cleaning behaviors exposed by the app. The lower-level
79
+ `VacuumModes`, `WaterModes`, and `CleanRoutes` enums are still useful for
80
+ fine-grained selection.
81
+ """
82
+
83
+ VACUUM = "vacuum"
84
+ VAC_AND_MOP = "vac_and_mop"
85
+ MOP = "mop"
86
+ CUSTOM = "custom"
87
+ SMART_MODE = "smart_mode"
88
+
89
+
71
90
  WATER_SLIDE_MODE_MAPPING: dict[int, WaterModes] = {
72
91
  200: WaterModes.OFF,
73
92
  221: WaterModes.PURE_WATER_FLOW_START,
@@ -78,6 +97,8 @@ WATER_SLIDE_MODE_MAPPING: dict[int, WaterModes] = {
78
97
  250: WaterModes.PURE_WATER_FLOW_END,
79
98
  }
80
99
 
100
+ ModeEnumT = TypeVar("ModeEnumT", bound=RoborockModeEnum)
101
+
81
102
 
82
103
  def get_wash_towel_modes(features: DeviceFeatures) -> list[WashTowelModes]:
83
104
  """Get the valid wash towel modes for the device"""
@@ -174,7 +195,151 @@ def get_water_mode_mapping(features: DeviceFeatures) -> dict[int, str]:
174
195
  return {mode.code: mode.value for mode in get_water_modes(features)}
175
196
 
176
197
 
177
- def is_mode_customized(clean_mode: VacuumModes, water_mode: WaterModes, mop_mode: CleanRoutes) -> bool:
198
+ def get_cleaning_mode_options(features: DeviceFeatures) -> list[CleaningMode]:
199
+ """Return the supported high-level cleaning modes for the device.
200
+
201
+ These options are the preferred user-facing choices because they bundle the
202
+ correct fan, water, and mop-route settings together for the device. Callers
203
+ should generally present these instead of mixing lower-level mode enums
204
+ unless they explicitly need fine-grained control.
205
+ """
206
+ if not features.is_support_water_mode:
207
+ return []
208
+
209
+ supported_water_modes = get_water_modes(features)
210
+ options = [CleaningMode.VACUUM, CleaningMode.VAC_AND_MOP]
211
+ if features.is_pure_clean_mop_supported:
212
+ options.append(CleaningMode.MOP)
213
+ if features.is_customized_clean_supported and WaterModes.CUSTOMIZED in supported_water_modes:
214
+ options.append(CleaningMode.CUSTOM)
215
+ if features.is_smart_clean_mode_set_supported and WaterModes.SMART_MODE in supported_water_modes:
216
+ options.append(CleaningMode.SMART_MODE)
217
+ return options
218
+
219
+
220
+ def get_mop_only_vacuum_mode(features: DeviceFeatures) -> VacuumModes:
221
+ """Determine the vacuum mode to use when you just want to mop.
222
+
223
+ There are three cases that must be handled:
224
+ 1. The device does not support only mopping.
225
+ 2. The device supports raising the vacuum brush while mopping
226
+ 3. All other cases.
227
+ """
228
+ if not features.is_pure_clean_mop_supported:
229
+ raise RoborockUnsupportedFeature("Mop-only cleaning is not supported")
230
+ if features.is_support_main_brush_up_down_supported:
231
+ return VacuumModes.OFF_RAISE_MAIN_BRUSH
232
+ return VacuumModes.OFF
233
+
234
+
235
+ def _get_default_mopping_water_mode(features: DeviceFeatures) -> WaterModes:
236
+ """Pick a sensible default water mode when mopping for the device."""
237
+ # Water-slide devices use a disjoint set of water codes; pick a mid-flow
238
+ # slide code instead of the standard 202, which they don't accept.
239
+ if features.is_water_slide_mode_supported:
240
+ return WaterModes.PURE_WATER_FLOW_MIDDLE
241
+ return WaterModes.STANDARD
242
+
243
+
244
+ def _get_clean_motor_mode_params(
245
+ mode: CleaningMode,
246
+ features: DeviceFeatures,
247
+ ) -> tuple[VacuumModes, WaterModes, CleanRoutes]:
248
+ """Return (fan_power, water_box_mode, mop_mode) enums for the high-level mode."""
249
+ if mode == CleaningMode.VACUUM:
250
+ return (VacuumModes.BALANCED, WaterModes.OFF, CleanRoutes.STANDARD)
251
+ if mode == CleaningMode.VAC_AND_MOP:
252
+ return (VacuumModes.BALANCED, _get_default_mopping_water_mode(features), CleanRoutes.STANDARD)
253
+ if mode == CleaningMode.MOP:
254
+ return (
255
+ get_mop_only_vacuum_mode(features),
256
+ _get_default_mopping_water_mode(features),
257
+ CleanRoutes.STANDARD,
258
+ )
259
+ if mode == CleaningMode.CUSTOM:
260
+ return (VacuumModes.CUSTOMIZED, WaterModes.CUSTOMIZED, CleanRoutes.CUSTOMIZED)
261
+ if mode == CleaningMode.SMART_MODE:
262
+ return (VacuumModes.SMART_MODE, WaterModes.SMART_MODE, CleanRoutes.SMART_MODE)
263
+ raise RoborockUnsupportedFeature(f"Cleaning mode {mode.value!r} is not supported")
264
+
265
+
266
+ def resolve_cleaning_mode(cleaning_mode: str | CleaningMode) -> CleaningMode:
267
+ """Resolve a string or enum into a CleaningMode value."""
268
+ if isinstance(cleaning_mode, CleaningMode):
269
+ return cleaning_mode
270
+ try:
271
+ return CleaningMode(cleaning_mode)
272
+ except ValueError as err:
273
+ raise RoborockUnsupportedFeature(f"Cleaning mode {cleaning_mode!r} is not supported") from err
274
+
275
+
276
+ def get_cleaning_mode_parameters(cleaning_mode: CleaningMode, features: DeviceFeatures) -> list[dict[str, int]]:
277
+ """Get the RPC payload for switching the high-level cleaning mode."""
278
+ if cleaning_mode not in get_cleaning_mode_options(features):
279
+ raise RoborockUnsupportedFeature(f"Cleaning mode {cleaning_mode.value!r} is not supported")
280
+
281
+ fan_power, water_box_mode, mop_mode = _get_clean_motor_mode_params(cleaning_mode, features)
282
+ params: dict[str, int] = {"fan_power": fan_power.code, "water_box_mode": water_box_mode.code}
283
+ if features.is_clean_route_setting_supported:
284
+ params["mop_mode"] = mop_mode.code
285
+ return [params]
286
+
287
+
288
+ def _resolve_mode_code(value: int | ModeEnumT | None, mode_cls: type[ModeEnumT]) -> ModeEnumT | None:
289
+ """Resolve a raw code or enum into a RoborockModeEnum."""
290
+ if value is None:
291
+ return None
292
+ if isinstance(value, mode_cls):
293
+ return value
294
+ return mode_cls.from_code_optional(int(value))
295
+
296
+
297
+ def _resolve_clean_mode(value: int | VacuumModes | None, features: DeviceFeatures) -> VacuumModes | None:
298
+ """Resolve a vacuum mode code, accounting for feature-specific code aliases."""
299
+ if value is None or isinstance(value, VacuumModes):
300
+ return value
301
+ if value == VacuumModes.OFF.code:
302
+ if features.is_pure_clean_mop_supported:
303
+ return get_mop_only_vacuum_mode(features)
304
+ return VacuumModes.GENTLE
305
+ return VacuumModes.from_code_optional(value)
306
+
307
+
308
+ def get_current_cleaning_mode(
309
+ clean_mode: int | VacuumModes | None,
310
+ water_mode: int | WaterModes | None,
311
+ mop_mode: int | CleanRoutes | None,
312
+ features: DeviceFeatures,
313
+ ) -> CleaningMode | None:
314
+ """Classify the current high-level cleaning mode from individual mode codes."""
315
+ if not features.is_support_water_mode:
316
+ return None
317
+ clean_mode_enum = _resolve_clean_mode(clean_mode, features)
318
+ water_mode_enum = _resolve_mode_code(water_mode, WaterModes)
319
+ mop_mode_enum = _resolve_mode_code(mop_mode, CleanRoutes)
320
+ if clean_mode_enum is None or water_mode_enum is None:
321
+ return None
322
+
323
+ if is_smart_mode_set(water_mode_enum, clean_mode_enum, mop_mode_enum):
324
+ return CleaningMode.SMART_MODE
325
+ if is_mode_customized(clean_mode_enum, water_mode_enum, mop_mode_enum):
326
+ return CleaningMode.CUSTOM
327
+ if water_mode_enum != WaterModes.OFF:
328
+ try:
329
+ if clean_mode_enum == get_mop_only_vacuum_mode(features):
330
+ return CleaningMode.MOP
331
+ except RoborockUnsupportedFeature:
332
+ pass
333
+ if water_mode_enum == WaterModes.OFF:
334
+ return CleaningMode.VACUUM
335
+ return CleaningMode.VAC_AND_MOP
336
+
337
+
338
+ def is_mode_customized(
339
+ clean_mode: VacuumModes | None,
340
+ water_mode: WaterModes | None,
341
+ mop_mode: CleanRoutes | None,
342
+ ) -> bool:
178
343
  """Check if any of the cleaning modes are set to a custom value."""
179
344
  return (
180
345
  clean_mode == VacuumModes.CUSTOMIZED
@@ -183,7 +348,11 @@ def is_mode_customized(clean_mode: VacuumModes, water_mode: WaterModes, mop_mode
183
348
  )
184
349
 
185
350
 
186
- def is_smart_mode_set(water_mode: WaterModes, clean_mode: VacuumModes, mop_mode: CleanRoutes) -> bool:
351
+ def is_smart_mode_set(
352
+ water_mode: WaterModes | None,
353
+ clean_mode: VacuumModes | None,
354
+ mop_mode: CleanRoutes | None,
355
+ ) -> bool:
187
356
  """Check if the smart mode is set for the given water mode and clean mode"""
188
357
  return (
189
358
  water_mode == WaterModes.SMART_MODE
@@ -26,6 +26,8 @@ from roborock.protocol import create_mqtt_params
26
26
  from roborock.web_api import RoborockApiClient, UserWebApiClient
27
27
 
28
28
  from .cache import Cache, DeviceCache, NoCache
29
+ from .rpc.b01_q7_channel import create_b01_q7_channel
30
+ from .rpc.b01_q10_channel import create_b01_q10_channel
29
31
  from .rpc.v1_channel import create_v1_channel
30
32
  from .traits import Trait, a01, b01, v1
31
33
  from .transport.channel import Channel
@@ -254,13 +256,22 @@ async def create_device_manager(
254
256
  channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
255
257
  trait = a01.create(product, channel)
256
258
  case DeviceVersion.B01:
257
- channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
259
+ mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
258
260
  model_part = product.model.split(".")[-1]
259
261
  if "ss" in model_part:
262
+ b01_q10_channel = create_b01_q10_channel(mqtt_channel)
263
+ channel = b01_q10_channel
260
264
  trait = b01.q10.create(channel)
261
265
  elif "sc" in model_part:
262
266
  # Q7 devices start with 'sc' in their model naming.
263
- trait = b01.q7.create(product, device, channel)
267
+ b01_q7_channel = create_b01_q7_channel(device, product, mqtt_channel)
268
+ channel = b01_q7_channel
269
+ trait = b01.q7.create(
270
+ product,
271
+ device,
272
+ rpc_channel=b01_q7_channel,
273
+ map_rpc_channel=b01_q7_channel,
274
+ )
264
275
  else:
265
276
  raise UnsupportedDeviceError(f"Device {device.name} has unsupported B01 model: {product.model}")
266
277
  case _:
@@ -1,9 +1,11 @@
1
1
  """Thin wrapper around the MQTT channel for Roborock B01 Q10 devices."""
2
2
 
3
3
  import logging
4
- from collections.abc import AsyncGenerator
4
+ from collections.abc import AsyncGenerator, Callable
5
+ from typing import Protocol
5
6
 
6
7
  from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
8
+ from roborock.devices.transport.channel import Channel
7
9
  from roborock.devices.transport.mqtt_channel import MqttChannel
8
10
  from roborock.exceptions import RoborockException
9
11
  from roborock.protocols.b01_q10_protocol import (
@@ -12,10 +14,53 @@ from roborock.protocols.b01_q10_protocol import (
12
14
  decode_message,
13
15
  encode_mqtt_payload,
14
16
  )
17
+ from roborock.roborock_message import RoborockMessage
15
18
 
16
19
  _LOGGER = logging.getLogger(__name__)
17
20
 
18
21
 
22
+ class Q10RpcChannel(Protocol):
23
+ """Protocol for Q10 RPC channels."""
24
+
25
+ async def send_command(
26
+ self,
27
+ command: B01_Q10_DP,
28
+ params: ParamsType = None,
29
+ ) -> None:
30
+ """Send a command on the MQTT channel, without waiting for a response."""
31
+ ...
32
+
33
+
34
+ class B01Q10Channel(Channel, Q10RpcChannel):
35
+ """Unified B01 Q10 channel wrapping MQTT transport."""
36
+
37
+ def __init__(self, mqtt_channel: MqttChannel) -> None:
38
+ self._mqtt_channel = mqtt_channel
39
+
40
+ @property
41
+ def is_connected(self) -> bool:
42
+ return self._mqtt_channel.is_connected
43
+
44
+ @property
45
+ def is_local_connected(self) -> bool:
46
+ return False
47
+
48
+ async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
49
+ return await self._mqtt_channel.subscribe(callback)
50
+
51
+ async def subscribe_stream(self) -> AsyncGenerator[Q10Message, None]:
52
+ """Stream decoded Q10 messages received via MQTT."""
53
+ async for msg in stream_decoded_messages(self._mqtt_channel):
54
+ yield msg
55
+
56
+ async def send_command(
57
+ self,
58
+ command: B01_Q10_DP,
59
+ params: ParamsType = None,
60
+ ) -> None:
61
+ await send_command(self._mqtt_channel, command, params)
62
+
63
+
19
64
  async def stream_decoded_messages(
20
65
  mqtt_channel: MqttChannel,
21
66
  ) -> AsyncGenerator[Q10Message, None]:
@@ -59,3 +104,8 @@ async def send_command(
59
104
  ex,
60
105
  )
61
106
  raise
107
+
108
+
109
+ def create_b01_q10_channel(mqtt_channel: MqttChannel) -> B01Q10Channel:
110
+ """Create a B01Q10Channel wrapping MQTT transport."""
111
+ return B01Q10Channel(mqtt_channel)
@@ -1,19 +1,23 @@
1
1
  """Thin wrapper around the MQTT channel for Roborock B01 Q7 devices."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  import asyncio
6
4
  import json
7
5
  import logging
8
6
  from collections.abc import Callable
9
- from typing import TypeAlias, TypeVar
7
+ from typing import Protocol, TypeAlias, TypeVar
10
8
 
9
+ from roborock.data import HomeDataDevice, HomeDataProduct
10
+ from roborock.devices.transport.channel import Channel
11
11
  from roborock.devices.transport.mqtt_channel import MqttChannel
12
12
  from roborock.exceptions import RoborockException
13
13
  from roborock.protocols.b01_q7_protocol import (
14
+ B01_Q7_DPS,
14
15
  B01_VERSION,
16
+ CommandType,
15
17
  MapKey,
18
+ ParamsType,
16
19
  Q7RequestMessage,
20
+ create_map_key,
17
21
  decode_map_payload,
18
22
  decode_rpc_response,
19
23
  encode_mqtt_payload,
@@ -26,6 +30,30 @@ _T = TypeVar("_T")
26
30
  DecodedB01Response: TypeAlias = dict[str, object] | str
27
31
 
28
32
 
33
+ class Q7RpcChannel(Protocol):
34
+ """Protocol for Q7 RPC channels."""
35
+
36
+ async def send_command(
37
+ self,
38
+ command: CommandType,
39
+ params: ParamsType = None,
40
+ ) -> DecodedB01Response:
41
+ """Send a command and get a decoded response."""
42
+ ...
43
+
44
+
45
+ class Q7MapRpcChannel(Protocol):
46
+ """Protocol for Q7 map RPC channels."""
47
+
48
+ async def send_map_command(
49
+ self,
50
+ command: CommandType,
51
+ params: ParamsType = None,
52
+ ) -> bytes:
53
+ """Send a map command and get decoded bytes."""
54
+ ...
55
+
56
+
29
57
  def _matches_map_response(response_message: RoborockMessage, *, version: bytes | None) -> bytes | None:
30
58
  """Return raw map payload bytes for matching MAP_RESPONSE messages."""
31
59
  if (
@@ -120,39 +148,55 @@ async def send_decoded_command(
120
148
  raise RoborockException(f"B01 command timed out after {_TIMEOUT}s ({request_message})") from ex
121
149
  except RoborockException as ex:
122
150
  _LOGGER.warning(
123
- "Error sending B01 decoded command (%ss): %s",
151
+ "Error sending B01 decoded command (%s): %s",
124
152
  request_message,
125
153
  ex,
126
154
  )
127
155
  raise
128
156
  except Exception as ex:
129
157
  _LOGGER.exception(
130
- "Error sending B01 decoded command (%ss): %s",
158
+ "Error sending B01 decoded command (%s): %s",
131
159
  request_message,
132
160
  ex,
133
161
  )
134
162
  raise
135
163
 
136
164
 
137
- class MapRpcChannel:
138
- """RPC channel for map-related commands on B01/Q7 devices."""
165
+ class B01Q7Channel(Channel, Q7RpcChannel, Q7MapRpcChannel):
166
+ """Unified B01 Q7 channel wrapping MQTT transport."""
139
167
 
140
168
  def __init__(self, mqtt_channel: MqttChannel, map_key: MapKey) -> None:
141
169
  self._mqtt_channel = mqtt_channel
142
170
  self._map_key = map_key
143
171
 
144
- async def send_map_command(self, request_message: Q7RequestMessage) -> bytes:
145
- """Send a map upload command and return decoded SCMap bytes.
146
-
147
- This publishes the request and waits for a matching ``MAP_RESPONSE`` message
148
- with the correct protocol version. The raw ``MAP_RESPONSE`` payload bytes are
149
- then decoded/inflated via :func:`decode_map_payload` using this channel's
150
- ``map_key``, and the resulting SCMap bytes are returned.
151
-
152
- The returned value is the decoded map data bytes suitable for passing to the
153
- map parser library, not the raw MQTT ``MAP_RESPONSE`` payload bytes.
154
- """
172
+ @property
173
+ def is_connected(self) -> bool:
174
+ return self._mqtt_channel.is_connected
175
+
176
+ @property
177
+ def is_local_connected(self) -> bool:
178
+ return False
179
+
180
+ async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
181
+ return await self._mqtt_channel.subscribe(callback)
182
+
183
+ async def send_command(
184
+ self,
185
+ command: CommandType,
186
+ params: ParamsType = None,
187
+ ) -> DecodedB01Response:
188
+ return await send_decoded_command(
189
+ self._mqtt_channel,
190
+ Q7RequestMessage(dps=B01_Q7_DPS, command=command, params=params),
191
+ )
155
192
 
193
+ async def send_map_command(
194
+ self,
195
+ command: CommandType,
196
+ params: ParamsType = None,
197
+ ) -> bytes:
198
+ """Send a map upload command and return decoded SCMap bytes."""
199
+ request_message = Q7RequestMessage(dps=B01_Q7_DPS, command=command, params=params)
156
200
  try:
157
201
  raw_payload = await _send_command(
158
202
  self._mqtt_channel,
@@ -163,3 +207,17 @@ class MapRpcChannel:
163
207
  raise RoborockException(f"B01 map command timed out after {_TIMEOUT}s ({request_message})") from ex
164
208
 
165
209
  return decode_map_payload(raw_payload, map_key=self._map_key)
210
+
211
+
212
+ def create_b01_q7_channel(
213
+ device: HomeDataDevice,
214
+ product: HomeDataProduct,
215
+ mqtt_channel: MqttChannel,
216
+ ) -> B01Q7Channel:
217
+ """Create a B01Q7Channel for the given device."""
218
+ if device.sn is None or product.model is None:
219
+ raise RoborockException(
220
+ f"Device serial number and product model are required (sn: {device.sn}, model: {product.model})"
221
+ )
222
+ map_key = create_map_key(serial=device.sn, model=product.model)
223
+ return B01Q7Channel(mqtt_channel, map_key)
@@ -4,9 +4,8 @@ import asyncio
4
4
  import logging
5
5
 
6
6
  from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
7
- from roborock.devices.rpc.b01_q10_channel import stream_decoded_messages
7
+ from roborock.devices.rpc.b01_q10_channel import B01Q10Channel
8
8
  from roborock.devices.traits import Trait
9
- from roborock.devices.transport.mqtt_channel import MqttChannel
10
9
  from roborock.map.b01_q10_map_parser import Q10MapPacket, Q10TracePacket
11
10
  from roborock.protocols.b01_q10_protocol import Q10DpsUpdate, Q10Message
12
11
 
@@ -83,7 +82,7 @@ class Q10PropertiesApi(Trait):
83
82
  clean_history: CleanHistoryTrait
84
83
  """Trait for fetching the device clean-record history (``dpCleanRecord``)."""
85
84
 
86
- def __init__(self, channel: MqttChannel) -> None:
85
+ def __init__(self, channel: B01Q10Channel) -> None:
87
86
  """Initialize the B01Props API."""
88
87
  self._channel = channel
89
88
  self.command = CommandTrait(channel)
@@ -134,7 +133,7 @@ class Q10PropertiesApi(Trait):
134
133
 
135
134
  async def _subscribe_loop(self) -> None:
136
135
  """Persistent loop dispatching decoded messages to the read-model traits."""
137
- async for message in stream_decoded_messages(self._channel):
136
+ async for message in self._channel.subscribe_stream():
138
137
  self._handle_message(message)
139
138
 
140
139
  def _handle_message(self, message: Q10Message) -> None:
@@ -157,6 +156,6 @@ class Q10PropertiesApi(Trait):
157
156
  trait.update_from_dps(message.dps)
158
157
 
159
158
 
160
- def create(channel: MqttChannel) -> Q10PropertiesApi:
159
+ def create(channel: B01Q10Channel) -> Q10PropertiesApi:
161
160
  """Create traits for B01 devices."""
162
161
  return Q10PropertiesApi(channel)
@@ -1,8 +1,7 @@
1
1
  from typing import Any
2
2
 
3
3
  from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
4
- from roborock.devices.rpc.b01_q10_channel import send_command
5
- from roborock.devices.transport.mqtt_channel import MqttChannel
4
+ from roborock.devices.rpc.b01_q10_channel import Q10RpcChannel
6
5
  from roborock.protocols.b01_q10_protocol import ParamsType
7
6
 
8
7
 
@@ -15,9 +14,9 @@ class CommandTrait:
15
14
  available.
16
15
  """
17
16
 
18
- def __init__(self, channel: MqttChannel) -> None:
17
+ def __init__(self, rpc_channel: Q10RpcChannel) -> None:
19
18
  """Initialize the CommandTrait."""
20
- self._channel = channel
19
+ self._rpc_channel = rpc_channel
21
20
 
22
21
  async def send(self, command: B01_Q10_DP, params: ParamsType = None) -> Any:
23
22
  """Send a command to the device.
@@ -27,6 +26,6 @@ class CommandTrait:
27
26
  caller to ensure that any traits affected by the command are refreshed
28
27
  as needed.
29
28
  """
30
- if not self._channel:
29
+ if not self._rpc_channel:
31
30
  raise ValueError("Device trait in invalid state")
32
- return await send_command(self._channel, command, params=params)
31
+ return await self._rpc_channel.send_command(command, params=params)
@@ -3,8 +3,6 @@
3
3
  Potentially other devices may fall into this category in the future.
4
4
  """
5
5
 
6
- from __future__ import annotations
7
-
8
6
  from typing import Any
9
7
 
10
8
  from roborock import B01Props
@@ -18,11 +16,10 @@ from roborock.data.b01_q7.b01_q7_code_mappings import (
18
16
  SCWindMapping,
19
17
  WaterLevelMapping,
20
18
  )
21
- from roborock.devices.rpc.b01_q7_channel import MapRpcChannel, send_decoded_command
19
+ from roborock.devices.rpc.b01_q7_channel import Q7MapRpcChannel, Q7RpcChannel
22
20
  from roborock.devices.traits import Trait
23
- from roborock.devices.transport.mqtt_channel import MqttChannel
24
21
  from roborock.exceptions import RoborockException
25
- from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, CommandType, ParamsType, Q7RequestMessage, create_map_key
22
+ from roborock.protocols.b01_q7_protocol import CommandType, ParamsType
26
23
  from roborock.roborock_message import RoborockB01Props
27
24
  from roborock.roborock_typing import RoborockB01Q7Methods
28
25
 
@@ -53,10 +50,14 @@ class Q7PropertiesApi(Trait):
53
50
  """Trait for fetching parsed current map content."""
54
51
 
55
52
  def __init__(
56
- self, channel: MqttChannel, map_rpc_channel: MapRpcChannel, device: HomeDataDevice, product: HomeDataProduct
53
+ self,
54
+ rpc_channel: Q7RpcChannel,
55
+ map_rpc_channel: Q7MapRpcChannel,
56
+ device: HomeDataDevice,
57
+ product: HomeDataProduct,
57
58
  ) -> None:
58
59
  """Initialize the Q7 API."""
59
- self._channel = channel
60
+ self._rpc_channel = rpc_channel
60
61
  self._map_rpc_channel = map_rpc_channel
61
62
  self._device = device
62
63
  self._product = product
@@ -64,8 +65,8 @@ class Q7PropertiesApi(Trait):
64
65
  if not device.sn or not product.model:
65
66
  raise ValueError("B01 Q7 map content requires device serial number and product model metadata")
66
67
 
67
- self.clean_summary = CleanSummaryTrait(channel)
68
- self.map = MapTrait(channel)
68
+ self.clean_summary = CleanSummaryTrait(rpc_channel)
69
+ self.map = MapTrait(rpc_channel)
69
70
  self.map_content = MapContentTrait(
70
71
  self._map_rpc_channel,
71
72
  self.map,
@@ -199,17 +200,23 @@ class Q7PropertiesApi(Trait):
199
200
 
200
201
  async def send(self, command: CommandType, params: ParamsType) -> Any:
201
202
  """Send a command to the device."""
202
- return await send_decoded_command(
203
- self._channel,
204
- Q7RequestMessage(dps=B01_Q7_DPS, command=command, params=params),
205
- )
203
+ return await self._rpc_channel.send_command(command, params)
206
204
 
207
205
 
208
- def create(product: HomeDataProduct, device: HomeDataDevice, channel: MqttChannel) -> Q7PropertiesApi:
206
+ def create(
207
+ product: HomeDataProduct,
208
+ device: HomeDataDevice,
209
+ rpc_channel: Q7RpcChannel,
210
+ map_rpc_channel: Q7MapRpcChannel,
211
+ ) -> Q7PropertiesApi:
209
212
  """Create traits for B01 Q7 devices."""
210
213
  if device.sn is None or product.model is None:
211
214
  raise RoborockException(
212
- f"Device serial number and product model are required (sn:: {device.sn}, model: {product.model})"
215
+ f"Device serial number and product model are required (sn: {device.sn}, model: {product.model})"
213
216
  )
214
- map_rpc_channel = MapRpcChannel(channel, map_key=create_map_key(serial=device.sn, model=product.model))
215
- return Q7PropertiesApi(channel, device=device, product=product, map_rpc_channel=map_rpc_channel)
217
+ return Q7PropertiesApi(
218
+ rpc_channel=rpc_channel,
219
+ map_rpc_channel=map_rpc_channel,
220
+ device=device,
221
+ product=product,
222
+ )
@@ -7,11 +7,9 @@ and a `record_list` whose items contain a JSON string in `detail`.
7
7
  import logging
8
8
 
9
9
  from roborock import CleanRecordDetail, CleanRecordList, CleanRecordSummary
10
- from roborock.devices.rpc.b01_q7_channel import send_decoded_command
10
+ from roborock.devices.rpc.b01_q7_channel import Q7RpcChannel
11
11
  from roborock.devices.traits import Trait
12
- from roborock.devices.transport.mqtt_channel import MqttChannel
13
12
  from roborock.exceptions import RoborockException
14
- from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, Q7RequestMessage
15
13
  from roborock.roborock_typing import RoborockB01Q7Methods
16
14
 
17
15
  __all__ = [
@@ -24,11 +22,11 @@ _LOGGER = logging.getLogger(__name__)
24
22
  class CleanSummaryTrait(CleanRecordSummary, Trait):
25
23
  """B01/Q7 clean summary + clean record access (via record list service)."""
26
24
 
27
- def __init__(self, channel: MqttChannel) -> None:
25
+ def __init__(self, channel: Q7RpcChannel) -> None:
28
26
  """Initialize the clean summary trait.
29
27
 
30
28
  Args:
31
- channel: MQTT channel used to communicate with the device.
29
+ channel: RPC channel used to communicate with the device.
32
30
  """
33
31
  super().__init__()
34
32
  self._channel = channel
@@ -46,9 +44,9 @@ class CleanSummaryTrait(CleanRecordSummary, Trait):
46
44
 
47
45
  async def _get_record_list(self) -> CleanRecordList:
48
46
  """Fetch the raw device clean record list (`service.get_record_list`)."""
49
- result = await send_decoded_command(
50
- self._channel,
51
- Q7RequestMessage(dps=B01_Q7_DPS, command=RoborockB01Q7Methods.GET_RECORD_LIST, params={}),
47
+ result = await self._channel.send_command(
48
+ command=RoborockB01Q7Methods.GET_RECORD_LIST,
49
+ params={},
52
50
  )
53
51
 
54
52
  if not isinstance(result, dict):
@@ -1,11 +1,9 @@
1
1
  """Map trait for B01 Q7 devices."""
2
2
 
3
3
  from roborock.data import Q7MapList
4
- from roborock.devices.rpc.b01_q7_channel import send_decoded_command
4
+ from roborock.devices.rpc.b01_q7_channel import Q7RpcChannel
5
5
  from roborock.devices.traits import Trait
6
- from roborock.devices.transport.mqtt_channel import MqttChannel
7
6
  from roborock.exceptions import RoborockException
8
- from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, Q7RequestMessage
9
7
  from roborock.roborock_typing import RoborockB01Q7Methods
10
8
 
11
9
 
@@ -16,15 +14,15 @@ class MapTrait(Q7MapList, Trait):
16
14
  current map ID to fetch.
17
15
  """
18
16
 
19
- def __init__(self, channel: MqttChannel) -> None:
17
+ def __init__(self, channel: Q7RpcChannel) -> None:
20
18
  super().__init__()
21
19
  self._channel = channel
22
20
 
23
21
  async def refresh(self) -> None:
24
22
  """Refresh cached map list metadata from the device."""
25
- response = await send_decoded_command(
26
- self._channel,
27
- Q7RequestMessage(dps=B01_Q7_DPS, command=RoborockB01Q7Methods.GET_MAP_LIST, params={}),
23
+ response = await self._channel.send_command(
24
+ command=RoborockB01Q7Methods.GET_MAP_LIST,
25
+ params={},
28
26
  )
29
27
  if not isinstance(response, dict):
30
28
  raise RoborockException(
@@ -14,11 +14,10 @@ from dataclasses import dataclass
14
14
  from vacuum_map_parser_base.map_data import MapData
15
15
 
16
16
  from roborock.data import RoborockBase
17
- from roborock.devices.rpc.b01_q7_channel import MapRpcChannel
17
+ from roborock.devices.rpc.b01_q7_channel import Q7MapRpcChannel
18
18
  from roborock.devices.traits import Trait
19
19
  from roborock.exceptions import RoborockException
20
20
  from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig
21
- from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, Q7RequestMessage
22
21
  from roborock.roborock_typing import RoborockB01Q7Methods
23
22
 
24
23
  from .map import MapTrait
@@ -55,7 +54,7 @@ class MapContentTrait(MapContent, Trait):
55
54
 
56
55
  def __init__(
57
56
  self,
58
- map_rpc_channel: MapRpcChannel,
57
+ map_rpc_channel: Q7MapRpcChannel,
59
58
  map_trait: MapTrait,
60
59
  *,
61
60
  map_parser_config: B01MapParserConfig | None = None,
@@ -77,13 +76,11 @@ class MapContentTrait(MapContent, Trait):
77
76
  if (map_id := self._map_trait.current_map_id) is None:
78
77
  raise RoborockException("Unable to determine current map ID")
79
78
 
80
- request = Q7RequestMessage(
81
- dps=B01_Q7_DPS,
82
- command=RoborockB01Q7Methods.UPLOAD_BY_MAPID,
83
- params={"map_id": map_id},
84
- )
85
79
  async with self._map_command_lock:
86
- raw_payload = await self._map_rpc_channel.send_map_command(request)
80
+ raw_payload = await self._map_rpc_channel.send_map_command(
81
+ RoborockB01Q7Methods.UPLOAD_BY_MAPID,
82
+ {"map_id": map_id},
83
+ )
87
84
 
88
85
  try:
89
86
  parsed_data = self._map_parser.parse(raw_payload)
@@ -3,14 +3,19 @@ from functools import cached_property
3
3
  from typing import Any
4
4
 
5
5
  from roborock import (
6
+ CleaningMode,
6
7
  CleanRoutes,
7
8
  StatusV2,
8
9
  VacuumModes,
9
10
  WaterModes,
10
11
  get_clean_modes,
11
12
  get_clean_routes,
13
+ get_cleaning_mode_options,
14
+ get_cleaning_mode_parameters,
15
+ get_current_cleaning_mode,
12
16
  get_water_mode_mapping,
13
17
  get_water_modes,
18
+ resolve_cleaning_mode,
14
19
  )
15
20
  from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
16
21
  from roborock.roborock_message import RoborockDataProtocol
@@ -42,10 +47,11 @@ class StatusTrait(StatusV2, common.V1TraitMixin, TraitUpdateListener):
42
47
  - Water Mode
43
48
  - Mop Route
44
49
 
45
- You should call the _options() version of the attribute to know which are supported for your device
46
- (i.e. fan_speed_options())
47
- Then you can call the _mapping to convert an int value to the actual Enum. (i.e. fan_speed_mapping())
48
- You can call the _name property to get the str value of the enum. (i.e. fan_speed_name)
50
+ You should use the _options version of the attribute to know which are
51
+ supported for your device (i.e. fan_speed_options)
52
+ Then you can use the _mapping to convert an int value to the actual Enum.
53
+ (i.e. fan_speed_mapping)
54
+ You can use the _name property to get the str value of the enum. (i.e. fan_speed_name)
49
55
 
50
56
  """
51
57
 
@@ -83,6 +89,10 @@ class StatusTrait(StatusV2, common.V1TraitMixin, TraitUpdateListener):
83
89
  def mop_route_mapping(self) -> dict[int, str]:
84
90
  return {route.code: route.value for route in self.mop_route_options}
85
91
 
92
+ @cached_property
93
+ def cleaning_mode_options(self) -> list[CleaningMode]:
94
+ return get_cleaning_mode_options(self._device_features_trait)
95
+
86
96
  @property
87
97
  def fan_speed_name(self) -> str | None:
88
98
  if self.fan_power is None:
@@ -101,6 +111,28 @@ class StatusTrait(StatusV2, common.V1TraitMixin, TraitUpdateListener):
101
111
  return None
102
112
  return self.mop_route_mapping.get(self.mop_mode)
103
113
 
114
+ @property
115
+ def current_cleaning_mode(self) -> CleaningMode | None:
116
+ return get_current_cleaning_mode(
117
+ clean_mode=self.fan_power,
118
+ water_mode=self.water_box_mode,
119
+ mop_mode=self.mop_mode,
120
+ features=self._device_features_trait,
121
+ )
122
+
123
+ @property
124
+ def current_cleaning_mode_name(self) -> str | None:
125
+ if (cleaning_mode := self.current_cleaning_mode) is None:
126
+ return None
127
+ return cleaning_mode.value
128
+
129
+ async def set_cleaning_mode(self, cleaning_mode: str | CleaningMode) -> None:
130
+ """Set the preferred high-level cleaning mode for the device."""
131
+ await self.rpc_channel.send_command(
132
+ RoborockCommand.SET_CLEAN_MOTOR_MODE,
133
+ params=get_cleaning_mode_parameters(resolve_cleaning_mode(cleaning_mode), self._device_features_trait),
134
+ )
135
+
104
136
  def update_from_dps(self, decoded_dps: dict[RoborockDataProtocol, Any]) -> None:
105
137
  """Update the trait from data protocol push message data.
106
138
 
@@ -40,6 +40,45 @@ class RoborockDataProtocol(RoborockEnum):
40
40
  raise ValueError(f"{key} not a valid key for Data Protocol")
41
41
 
42
42
 
43
+ class RoborockMowerDataProtocol(RoborockEnum):
44
+ UNKNOWN = 0
45
+ ERROR_CODE = 120
46
+ BATTERY = 121
47
+ MOW_TYPE = 122
48
+ MOW_STATE = 123
49
+ MAPPING_TYPE = 124
50
+ MAPPING_STATE = 125
51
+ OTA_STATE = 126
52
+ CHARGE_STATE = 127
53
+ DOCK_STATE = 128
54
+ CHARGE_TYPE = 129
55
+ PEND_TYPE = 130
56
+ REMOTE_STATE = 131
57
+ MOW_START_TYPE = 132
58
+ MOW_EFF_MODE = 133
59
+ MOW_HEIGHT = 134
60
+ MOW_DIRECTION_ANGLE = 135
61
+ MOW_PATTERN = 136
62
+ MOW_CONF_MODE = 137
63
+ OFFLINE_STATUS = 138
64
+ MOW_PROGRESS = 139
65
+ BLADE_LIFESPAN = 140
66
+ FC_STATE = 141
67
+ GPS_COORDINATE = 142
68
+ OFF_DOCK_NO_TASK_STATUS = 143
69
+ AFS_STATUS = 144
70
+ NETWORK_CHANNEL = 145
71
+ START = 201
72
+ DOCK = 202
73
+ PAUSE = 203
74
+ RESUME = 204
75
+ STOP = 205
76
+
77
+ @classmethod
78
+ def _missing_(cls: type[Self], key) -> Self:
79
+ raise ValueError(f"{key} not a valid key for Mower Data Protocol")
80
+
81
+
43
82
  class RoborockDyadDataProtocol(RoborockEnum):
44
83
  DRYING_STATUS = 134
45
84
  START = 200