python-roborock 2.37.0__tar.gz → 2.39.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 (55) hide show
  1. {python_roborock-2.37.0 → python_roborock-2.39.0}/PKG-INFO +1 -1
  2. {python_roborock-2.37.0 → python_roborock-2.39.0}/pyproject.toml +1 -1
  3. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/__init__.py +1 -0
  4. python_roborock-2.39.0/roborock/b01_containers.py +417 -0
  5. python_roborock-2.39.0/roborock/callbacks.py +126 -0
  6. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/clean_modes.py +1 -14
  7. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/cli.py +158 -2
  8. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/code_mappings.py +21 -1
  9. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/containers.py +3 -0
  10. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/device_features.py +1 -1
  11. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/device_manager.py +2 -2
  12. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/local_channel.py +5 -20
  13. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/mqtt_channel.py +3 -13
  14. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/traits/b01/props.py +3 -1
  15. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/mqtt/roborock_session.py +7 -13
  16. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/protocols/b01_protocol.py +10 -1
  17. {python_roborock-2.37.0 → python_roborock-2.39.0}/LICENSE +0 -0
  18. {python_roborock-2.37.0 → python_roborock-2.39.0}/README.md +0 -0
  19. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/api.py +0 -0
  20. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/cloud_api.py +0 -0
  21. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/command_cache.py +0 -0
  22. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/const.py +0 -0
  23. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/README.md +0 -0
  24. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/__init__.py +0 -0
  25. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/a01_channel.py +0 -0
  26. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/b01_channel.py +0 -0
  27. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/cache.py +0 -0
  28. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/channel.py +0 -0
  29. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/device.py +0 -0
  30. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/traits/b01/__init__.py +0 -0
  31. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/traits/dyad.py +0 -0
  32. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/traits/status.py +0 -0
  33. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/traits/trait.py +0 -0
  34. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/traits/zeo.py +0 -0
  35. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/v1_channel.py +0 -0
  36. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/devices/v1_rpc_channel.py +0 -0
  37. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/exceptions.py +0 -0
  38. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/mqtt/__init__.py +0 -0
  39. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/mqtt/session.py +0 -0
  40. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/protocol.py +0 -0
  41. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/protocols/a01_protocol.py +0 -0
  42. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/protocols/v1_protocol.py +0 -0
  43. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/py.typed +0 -0
  44. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/roborock_future.py +0 -0
  45. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/roborock_message.py +0 -0
  46. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/roborock_typing.py +0 -0
  47. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/util.py +0 -0
  48. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/version_1_apis/__init__.py +0 -0
  49. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  50. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  51. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  52. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/version_a01_apis/__init__.py +0 -0
  53. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  54. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
  55. {python_roborock-2.37.0 → python_roborock-2.39.0}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-roborock
3
- Version: 2.37.0
3
+ Version: 2.39.0
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Home-page: https://github.com/humbertogontijo/python-roborock
6
6
  License: GPL-3.0-only
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-roborock"
3
- version = "2.37.0"
3
+ version = "2.39.0"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = ["humbertogontijo <humbertogontijo@users.noreply.github.com>"]
6
6
  license = "GPL-3.0-only"
@@ -1,5 +1,6 @@
1
1
  """Roborock API."""
2
2
 
3
+ from roborock.b01_containers import *
3
4
  from roborock.code_mappings import *
4
5
  from roborock.containers import *
5
6
  from roborock.exceptions import *
@@ -0,0 +1,417 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ from .code_mappings import RoborockModeEnum
4
+ from .containers import RoborockBase
5
+
6
+
7
+ class WorkStatusMapping(RoborockModeEnum):
8
+ """Maps the general status of the robot."""
9
+
10
+ SLEEPING = ("sleeping", 0)
11
+ WAITING_FOR_ORDERS = ("waiting_for_orders", 1)
12
+ PAUSED = ("paused", 2)
13
+ DOCKING = ("docking", 3)
14
+ CHARGING = ("charging", 4)
15
+ SWEEP_MOPING = ("sweep_moping", 5)
16
+ SWEEP_MOPING_2 = ("sweep_moping_2", 6)
17
+ MOPING = ("moping", 7)
18
+ UPDATING = ("updating", 8)
19
+ MOP_CLEANING = ("mop_cleaning", 9)
20
+ MOP_AIRDRYING = ("mop_airdrying", 10)
21
+
22
+
23
+ class SCWindMapping(RoborockModeEnum):
24
+ """Maps suction power levels."""
25
+
26
+ SILENCE = ("quiet", 0)
27
+ STANDARD = ("balanced", 1)
28
+ STRONG = ("turbo", 2)
29
+ SUPER_STRONG = ("max", 3)
30
+ MAX = ("max_plus", 4)
31
+
32
+
33
+ class WaterLevelMapping(RoborockModeEnum):
34
+ """Maps water flow levels."""
35
+
36
+ LOW = ("low", 0)
37
+ MEDIUM = ("medium", 1)
38
+ HIGH = ("high", 2)
39
+
40
+
41
+ class CleanTypeMapping(RoborockModeEnum):
42
+ """Maps the type of cleaning (Vacuum, Mop, or both)."""
43
+
44
+ VACUUM = ("vacuum", 0)
45
+ VAC_AND_MOP = ("vac_and_mop", 1)
46
+ MOP = ("mop", 2)
47
+
48
+
49
+ class CleanRepeatMapping(RoborockModeEnum):
50
+ """Maps the cleaning repeat parameter."""
51
+
52
+ ONCE = ("once", 0)
53
+ TWICE = ("twice", 1)
54
+
55
+
56
+ class WorkModeMapping(RoborockModeEnum):
57
+ """Maps the detailed work modes of the robot."""
58
+
59
+ IDLE = ("idle", 0)
60
+ AUTO = ("auto", 1)
61
+ MANUAL = ("manual", 2)
62
+ AREA = ("area", 3)
63
+ AUTO_PAUSE = ("auto_pause", 4)
64
+ BACK_CHARGE = ("back_charge", 5)
65
+ POINT = ("point", 6)
66
+ NAVI = ("navi", 7)
67
+ AREA_PAUSE = ("area_pause", 8)
68
+ NAVI_PAUSE = ("navi_pause", 9)
69
+ GLOBAL_GO_HOME = ("global_go_home", 10)
70
+ GLOBAL_BROKEN = ("global_broken", 11)
71
+ NAVI_GO_HOME = ("navi_go_home", 12)
72
+ POINT_GO_HOME = ("point_go_home", 13)
73
+ NAVI_IDLE = ("navi_idle", 14)
74
+ SCREW = ("screw", 20)
75
+ SCREW_GO_HOME = ("screw_go_home", 21)
76
+ POINT_IDLE = ("point_idle", 22)
77
+ SCREW_IDLE = ("screw_idle", 23)
78
+ BORDER = ("border", 25)
79
+ BORDER_GO_HOME = ("border_go_home", 26)
80
+ BORDER_PAUSE = ("border_pause", 27)
81
+ BORDER_BROKEN = ("border_broken", 28)
82
+ BORDER_IDLE = ("border_idle", 29)
83
+ PLAN_AREA = ("plan_area", 30)
84
+ PLAN_AREA_PAUSE = ("plan_area_pause", 31)
85
+ PLAN_AREA_GO_HOME = ("plan_area_go_home", 32)
86
+ PLAN_AREA_BROKEN = ("plan_area_broken", 33)
87
+ PLAN_AREA_IDLE = ("plan_area_idle", 35)
88
+ MOPPING = ("mopping", 36)
89
+ MOPPING_PAUSE = ("mopping_pause", 37)
90
+ MOPPING_GO_HOME = ("mopping_go_home", 38)
91
+ MOPPING_BROKEN = ("mopping_broken", 39)
92
+ MOPPING_IDLE = ("mopping_idle", 40)
93
+ EXPLORING = ("exploring", 45)
94
+ EXPLORE_PAUSE = ("explore_pause", 46)
95
+ EXPLORE_GO_HOME = ("explore_go_home", 47)
96
+ EXPLORE_BROKEN = ("explore_broken", 48)
97
+ EXPLORE_IDLE = ("explore_idle", 49)
98
+
99
+
100
+ class StationActionMapping(RoborockModeEnum):
101
+ """Maps actions for the cleaning/drying station."""
102
+
103
+ STOP_CLEAN_OR_AIRDRY = ("stop_clean_or_airdry", 0)
104
+ MOP_CLEAN = ("mop_clean", 1)
105
+ MOP_AIRDRY = ("mop_airdry", 2)
106
+
107
+
108
+ class CleanTaskTypeMapping(RoborockModeEnum):
109
+ """Maps the high-level type of cleaning task selected."""
110
+
111
+ ALL = ("full", 0)
112
+ ROOM = ("room", 1)
113
+ AREA = ("zones", 4)
114
+ ROOM_NORMAL = ("room_normal", 5)
115
+ CUSTOM_MODE = ("customize", 6)
116
+ ALL_CUSTOM = ("all_custom", 11)
117
+ AREA_CUSTOM = ("area_custom", 99)
118
+
119
+
120
+ class CarpetModeMapping(RoborockModeEnum):
121
+ """Maps carpet handling parameters."""
122
+
123
+ FOLLOW_GLOBAL = ("follow_global", 0)
124
+ ON = ("on", 1)
125
+ OFF = ("off", 2)
126
+
127
+
128
+ @dataclass
129
+ class NetStatus(RoborockBase):
130
+ """Represents the network status of the device."""
131
+
132
+ rssi: str
133
+ loss: int
134
+ ping: int
135
+ ip: str
136
+ mac: str
137
+ ssid: str
138
+ frequency: int
139
+ bssid: str
140
+
141
+
142
+ @dataclass
143
+ class OrderTotal(RoborockBase):
144
+ """Represents the order total information."""
145
+
146
+ total: int
147
+ enable: int
148
+
149
+
150
+ @dataclass
151
+ class Privacy(RoborockBase):
152
+ """Represents the privacy settings of the device."""
153
+
154
+ ai_recognize: int
155
+ dirt_recognize: int
156
+ pet_recognize: int
157
+ carpet_turbo: int
158
+ carpet_avoid: int
159
+ carpet_show: int
160
+ map_uploads: int
161
+ ai_agent: int
162
+ ai_avoidance: int
163
+ record_uploads: int
164
+ along_floor: int
165
+ auto_upgrade: int
166
+
167
+
168
+ @dataclass
169
+ class PvCharging(RoborockBase):
170
+ """Represents the photovoltaic charging status."""
171
+
172
+ status: int
173
+ begin_time: int
174
+ end_time: int
175
+
176
+
177
+ @dataclass
178
+ class Recommend(RoborockBase):
179
+ """Represents cleaning recommendations."""
180
+
181
+ sill: int
182
+ wall: int
183
+ room_id: list[int] = field(default_factory=list)
184
+
185
+
186
+ class B01Fault(RoborockModeEnum):
187
+ """B01 fault codes and their descriptions."""
188
+
189
+ F_0 = ("fault_0", 0)
190
+ F_407 = ("cleaning_in_progress", 407) # Cleaning in progress. Scheduled cleanup ignored.
191
+ F_500 = (
192
+ "lidar_blocked",
193
+ 500,
194
+ ) # LiDAR turret or laser blocked. Check for obstruction and retry. LiDAR sensor obstructed or stuck.
195
+ # Remove foreign objects if any. If the problem persists, move the robot away and restart.
196
+ F_501 = (
197
+ "robot_suspended",
198
+ 501,
199
+ ) # Robot suspended. Move the robot away and restart. Cliff sensors dirty. Wipe them clean.
200
+ F_502 = (
201
+ "low_battery",
202
+ 502,
203
+ ) # Low battery. Recharge now. Battery low. Put the robot on the dock to charge it to 20% before starting.
204
+ F_503 = (
205
+ "dustbin_not_installed",
206
+ 503,
207
+ ) # Check that the dustbin and filter are installed properly. Reinstall the dustbin and filter in place.
208
+ # If the problem persists, replace the filter.
209
+ F_504 = ("fault_504", 504)
210
+ F_505 = ("fault_505", 505)
211
+ F_506 = ("fault_506", 506)
212
+ F_507 = ("fault_507", 507)
213
+ F_508 = ("fault_508", 508)
214
+ F_509 = ("cliff_sensor_error", 509) # Cliff sensors error. Clean them, move the robot away from drops, and restart.
215
+ F_510 = (
216
+ "bumper_stuck",
217
+ 510,
218
+ ) # Bumper stuck. Clean it and lightly tap to release it. Tap it repeatedly to release it. If no foreign object
219
+ # exists, move the robot away and restart.
220
+ F_511 = (
221
+ "docking_error",
222
+ 511,
223
+ ) # Docking error. Put the robot on the dock. Clear obstacles around the dock, clean charging contacts, and put
224
+ # the robot on the dock.
225
+ F_512 = (
226
+ "docking_error",
227
+ 512,
228
+ ) # Docking error. Put the robot on the dock. Clear obstacles around the dock, clean charging contacts, and put
229
+ # the robot on the dock.
230
+ F_513 = (
231
+ "robot_trapped",
232
+ 513,
233
+ ) # Robot trapped. Move the robot away and restart. Clear obstacles around robot or move robot away and restart.
234
+ F_514 = (
235
+ "robot_trapped",
236
+ 514,
237
+ ) # Robot trapped. Move the robot away and restart. Clear obstacles around robot or move robot away and restart.
238
+ F_515 = ("fault_515", 515)
239
+ F_517 = ("fault_517", 517)
240
+ F_518 = (
241
+ "low_battery",
242
+ 518,
243
+ ) # Low battery. Recharge now. Battery low. Put the robot on the dock to charge it to 20% before starting.
244
+ F_519 = ("fault_519", 519)
245
+ F_520 = ("fault_520", 520)
246
+ F_521 = ("fault_521", 521)
247
+ F_522 = ("mop_not_installed", 522) # Check that the mop is properly installed. Mop not installed. Reinstall it.
248
+ F_523 = ("fault_523", 523)
249
+ F_525 = ("fault_525", 525)
250
+ F_526 = ("fault_526", 526)
251
+ F_527 = ("fault_527", 527)
252
+ F_528 = ("fault_528", 528)
253
+ F_529 = ("fault_529", 529)
254
+ F_530 = ("fault_530", 530)
255
+ F_531 = ("fault_531", 531)
256
+ F_532 = ("fault_532", 532)
257
+ F_533 = ("long_sleep", 533) # About to shut down after a long time of sleep. Charge the robot.
258
+ F_534 = (
259
+ "low_battery_shutdown",
260
+ 534,
261
+ ) # Low battery. Turning off. About to shut down due to low battery. Charge the robot.
262
+ F_535 = ("fault_535", 535)
263
+ F_536 = ("fault_536", 536)
264
+ F_540 = ("fault_540", 540)
265
+ F_541 = ("fault_541", 541)
266
+ F_542 = ("fault_542", 542)
267
+ F_550 = ("fault_550", 550)
268
+ F_551 = ("fault_551", 551)
269
+ F_559 = ("fault_559", 559)
270
+ F_560 = ("side_brush_entangled", 560) # Side brush entangled. Remove and clean it.
271
+ F_561 = ("fault_561", 561)
272
+ F_562 = ("fault_562", 562)
273
+ F_563 = ("fault_563", 563)
274
+ F_564 = ("fault_564", 564)
275
+ F_565 = ("fault_565", 565)
276
+ F_566 = ("fault_566", 566)
277
+ F_567 = ("fault_567", 567)
278
+ F_568 = ("main_wheels_entangled", 568) # Clean main wheels, move the robot away and restart.
279
+ F_569 = ("main_wheels_entangled", 569) # Clean main wheels, move the robot away and restart.
280
+ F_570 = ("main_brush_entangled", 570) # Main brush entangled. Remove and clean it and its bearing.
281
+ F_571 = ("fault_571", 571)
282
+ F_572 = ("main_brush_entangled", 572) # Main brush entangled. Remove and clean it and its bearing.
283
+ F_573 = ("fault_573", 573)
284
+ F_574 = ("fault_574", 574)
285
+ F_580 = ("fault_580", 580)
286
+ F_581 = ("fault_581", 581)
287
+ F_582 = ("fault_582", 582)
288
+ F_583 = ("fault_583", 583)
289
+ F_584 = ("fault_584", 584)
290
+ F_585 = ("fault_585", 585)
291
+ F_586 = ("fault_586", 586)
292
+ F_587 = ("fault_587", 587)
293
+ F_588 = ("fault_588", 588)
294
+ F_589 = ("fault_589", 589)
295
+ F_590 = ("fault_590", 590)
296
+ F_591 = ("fault_591", 591)
297
+ F_592 = ("fault_592", 592)
298
+ F_593 = ("fault_593", 593)
299
+ F_594 = (
300
+ "dust_bag_not_installed",
301
+ 594,
302
+ ) # Make sure the dust bag is properly installed. Dust bag not installed. Check that it is installed properly.
303
+ F_601 = ("fault_601", 601)
304
+ F_602 = ("fault_602", 602)
305
+ F_603 = ("fault_603", 603)
306
+ F_604 = ("fault_604", 604)
307
+ F_605 = ("fault_605", 605)
308
+ F_611 = ("positioning_failed", 611) # Positioning failed. Move the robot back to the dock and remap.
309
+ F_612 = (
310
+ "map_changed",
311
+ 612,
312
+ ) # Map changed. Positioning failed. Try again. New environment detected. Map changed. Positioning failed.
313
+ # Try again after remapping.
314
+ F_629 = ("mop_mount_fell_off", 629) # Mop cloth mount fell off. Reinstall it to resume working.
315
+ F_668 = (
316
+ "system_error",
317
+ 668,
318
+ ) # Robot error. Reset the system. Fan error. Reset the system. If the problem persists, contact customer service.
319
+ F_2000 = ("fault_2000", 2000)
320
+ F_2003 = ("low_battery_schedule_canceled", 2003) # Battery level below 20%. Scheduled task canceled.
321
+ F_2007 = (
322
+ "cannot_reach_target",
323
+ 2007,
324
+ ) # Unable to reach the target. Cleaning ended. Ensure the door to the target area is open or unobstructed.
325
+ F_2012 = (
326
+ "cannot_reach_target",
327
+ 2012,
328
+ ) # Unable to reach the target. Cleaning ended. Ensure the door to the target area is open or unobstructed.
329
+ F_2013 = ("fault_2013", 2013)
330
+ F_2015 = ("fault_2015", 2015)
331
+ F_2017 = ("fault_2017", 2017)
332
+ F_2100 = (
333
+ "low_battery_resume_later",
334
+ 2100,
335
+ ) # Low battery. Resume cleaning after recharging. Low battery. Starting to recharge. Resume cleaning after
336
+ # charging.
337
+ F_2101 = ("fault_2101", 2101)
338
+ F_2102 = ("cleaning_complete", 2102) # Cleaning completed. Returning to the dock.
339
+ F_2103 = ("fault_2103", 2103)
340
+ F_2104 = ("fault_2104", 2104)
341
+ F_2105 = ("fault_2105", 2105)
342
+ F_2108 = ("fault_2108", 2108)
343
+ F_2109 = ("fault_2109", 2109)
344
+ F_2110 = ("fault_2110", 2110)
345
+ F_2111 = ("fault_2111", 2111)
346
+ F_2112 = ("fault_2112", 2112)
347
+ F_2113 = ("fault_2113", 2113)
348
+ F_2114 = ("fault_2114", 2114)
349
+ F_2115 = ("fault_2115", 2115)
350
+
351
+
352
+ @dataclass
353
+ class B01Props(RoborockBase):
354
+ """
355
+ Represents the complete properties and status for a Roborock B01 model.
356
+ This dataclass is generated based on the device's status JSON object.
357
+ """
358
+
359
+ status: WorkStatusMapping
360
+ fault: B01Fault
361
+ wind: SCWindMapping
362
+ water: int
363
+ mode: int
364
+ quantity: int
365
+ alarm: int
366
+ volume: int
367
+ hypa: int
368
+ main_brush: int
369
+ side_brush: int
370
+ mop_life: int
371
+ main_sensor: int
372
+ net_status: NetStatus
373
+ repeat_state: int
374
+ tank_state: int
375
+ sweep_type: int
376
+ clean_path_preference: int
377
+ cloth_state: int
378
+ time_zone: int
379
+ time_zone_info: str
380
+ language: int
381
+ cleaning_time: int
382
+ real_clean_time: int
383
+ cleaning_area: int
384
+ custom_type: int
385
+ sound: int
386
+ work_mode: WorkModeMapping
387
+ station_act: int
388
+ charge_state: int
389
+ current_map_id: int
390
+ map_num: int
391
+ dust_action: int
392
+ quiet_is_open: int
393
+ quiet_begin_time: int
394
+ quiet_end_time: int
395
+ clean_finish: int
396
+ voice_type: int
397
+ voice_type_version: int
398
+ order_total: OrderTotal
399
+ build_map: int
400
+ privacy: Privacy
401
+ dust_auto_state: int
402
+ dust_frequency: int
403
+ child_lock: int
404
+ multi_floor: int
405
+ map_save: int
406
+ light_mode: int
407
+ green_laser: int
408
+ dust_bag_used: int
409
+ order_save_mode: int
410
+ manufacturer: str
411
+ back_to_wash: int
412
+ charge_station_type: int
413
+ pv_cut_charge: int
414
+ pv_charging: PvCharging
415
+ serial_number: str
416
+ recommend: Recommend
417
+ add_sweep_status: int
@@ -0,0 +1,126 @@
1
+ """Module for managing callback utility functions."""
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+ from typing import Generic, TypeVar
6
+
7
+ _LOGGER = logging.getLogger(__name__)
8
+
9
+ K = TypeVar("K")
10
+ V = TypeVar("V")
11
+
12
+
13
+ def safe_callback(callback: Callable[[V], None], logger: logging.Logger | None = None) -> Callable[[V], None]:
14
+ """Wrap a callback to catch and log exceptions.
15
+
16
+ This is useful for ensuring that errors in callbacks do not propagate
17
+ and cause unexpected behavior. Any failures during callback execution will be logged.
18
+ """
19
+
20
+ if logger is None:
21
+ logger = _LOGGER
22
+
23
+ def wrapper(value: V) -> None:
24
+ try:
25
+ callback(value)
26
+ except Exception as ex: # noqa: BLE001
27
+ logger.error("Uncaught error in callback '%s': %s", callback.__name__, ex)
28
+
29
+ return wrapper
30
+
31
+
32
+ class CallbackMap(Generic[K, V]):
33
+ """A mapping of callbacks for specific keys.
34
+
35
+ This allows for registering multiple callbacks for different keys and invoking them
36
+ when a value is received for a specific key.
37
+ """
38
+
39
+ def __init__(self, logger: logging.Logger | None = None) -> None:
40
+ self._callbacks: dict[K, list[Callable[[V], None]]] = {}
41
+ self._logger = logger or _LOGGER
42
+
43
+ def keys(self) -> list[K]:
44
+ """Get all keys in the callback map."""
45
+ return list(self._callbacks.keys())
46
+
47
+ def add_callback(self, key: K, callback: Callable[[V], None]) -> Callable[[], None]:
48
+ """Add a callback for a specific key.
49
+
50
+ Any failures during callback execution will be logged.
51
+
52
+ Returns a callable that can be used to remove the callback.
53
+ """
54
+ self._callbacks.setdefault(key, []).append(callback)
55
+
56
+ def remove_callback() -> None:
57
+ """Remove the callback for the specific key."""
58
+ if cb_list := self._callbacks.get(key):
59
+ cb_list.remove(callback)
60
+ if not cb_list:
61
+ del self._callbacks[key]
62
+
63
+ return remove_callback
64
+
65
+ def get_callbacks(self, key: K) -> list[Callable[[V], None]]:
66
+ """Get all callbacks for a specific key."""
67
+ return self._callbacks.get(key, [])
68
+
69
+ def __call__(self, key: K, value: V) -> None:
70
+ """Invoke all callbacks for a specific key."""
71
+ for callback in self.get_callbacks(key):
72
+ safe_callback(callback, self._logger)(value)
73
+
74
+
75
+ class CallbackList(Generic[V]):
76
+ """A list of callbacks that can be invoked.
77
+
78
+ This combines a list of callbacks into a single callable. Callers can add
79
+ additional callbacks to the list at any time.
80
+ """
81
+
82
+ def __init__(self, logger: logging.Logger | None = None) -> None:
83
+ self._callbacks: list[Callable[[V], None]] = []
84
+ self._logger = logger or _LOGGER
85
+
86
+ def add_callback(self, callback: Callable[[V], None]) -> Callable[[], None]:
87
+ """Add a callback to the list.
88
+
89
+ Any failures during callback execution will be logged.
90
+
91
+ Returns a callable that can be used to remove the callback.
92
+ """
93
+ self._callbacks.append(callback)
94
+
95
+ return lambda: self._callbacks.remove(callback)
96
+
97
+ def __call__(self, value: V) -> None:
98
+ """Invoke all callbacks in the list."""
99
+ for callback in self._callbacks:
100
+ safe_callback(callback, self._logger)(value)
101
+
102
+
103
+ def decoder_callback(
104
+ decoder: Callable[[K], list[V]], callback: Callable[[V], None], logger: logging.Logger | None = None
105
+ ) -> Callable[[K], None]:
106
+ """Create a callback that decodes messages using a decoder and invokes a callback.
107
+
108
+ The decoder converts a value into a list of values. The callback is then invoked
109
+ for each value in the list.
110
+
111
+ Any failures during decoding or invoking the callbacks will be logged.
112
+ """
113
+ if logger is None:
114
+ logger = _LOGGER
115
+
116
+ safe_cb = safe_callback(callback, logger)
117
+
118
+ def wrapper(data: K) -> None:
119
+ if not (messages := decoder(data)):
120
+ logger.warning("Failed to decode message: %s", data)
121
+ return
122
+ for message in messages:
123
+ _LOGGER.debug("Decoded message: %s", message)
124
+ safe_cb(message)
125
+
126
+ return wrapper
@@ -1,21 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from enum import StrEnum
4
-
5
3
  from roborock import DeviceFeatures
6
4
 
7
-
8
- class RoborockModeEnum(StrEnum):
9
- """A custom StrEnum that also stores an integer code for each member."""
10
-
11
- code: int
12
-
13
- def __new__(cls, value: str, code: int) -> RoborockModeEnum:
14
- """Creates a new enum member."""
15
- member = str.__new__(cls, value)
16
- member._value_ = value
17
- member.code = code
18
- return member
5
+ from .code_mappings import RoborockModeEnum
19
6
 
20
7
 
21
8
  class CleanModes(RoborockModeEnum):
@@ -1,16 +1,17 @@
1
1
  import asyncio
2
2
  import json
3
3
  import logging
4
- from dataclasses import dataclass
4
+ from dataclasses import asdict, dataclass
5
5
  from pathlib import Path
6
6
  from typing import Any
7
7
 
8
8
  import click
9
+ import yaml
9
10
  from pyshark import FileCapture # type: ignore
10
11
  from pyshark.capture.live_capture import LiveCapture, UnknownInterfaceException # type: ignore
11
12
  from pyshark.packet.packet import Packet # type: ignore
12
13
 
13
- from roborock import RoborockException
14
+ from roborock import SHORT_MODEL_TO_ENUM, DeviceFeatures, RoborockCommand, RoborockException
14
15
  from roborock.containers import DeviceData, HomeData, HomeDataProduct, LoginData, NetworkInfo, RoborockBase, UserData
15
16
  from roborock.devices.cache import Cache, CacheData
16
17
  from roborock.devices.device_manager import create_device_manager, create_home_data_api
@@ -325,6 +326,159 @@ async def parser(_, local_key, device_ip, file):
325
326
  )
326
327
 
327
328
 
329
+ @click.command()
330
+ @click.pass_context
331
+ @run_sync()
332
+ async def get_device_info(ctx: click.Context):
333
+ """
334
+ Connects to devices and prints their feature information in YAML format.
335
+ """
336
+ click.echo("Discovering devices...")
337
+ context: RoborockContext = await _load_and_discover(ctx)
338
+ cache_data = context.cache_data()
339
+
340
+ home_data = cache_data.home_data
341
+
342
+ all_devices = home_data.devices + home_data.received_devices
343
+ if not all_devices:
344
+ click.echo("No devices found.")
345
+ return
346
+
347
+ click.echo(f"Found {len(all_devices)} devices. Fetching data...")
348
+
349
+ all_products_data = {}
350
+
351
+ for device in all_devices:
352
+ click.echo(f" - Processing {device.name} ({device.duid})")
353
+ product_info = home_data.product_map[device.product_id]
354
+ device_data = DeviceData(device, product_info.model)
355
+ mqtt_client = RoborockMqttClientV1(cache_data.user_data, device_data)
356
+
357
+ try:
358
+ init_status_result = await mqtt_client.send_command(
359
+ RoborockCommand.APP_GET_INIT_STATUS,
360
+ )
361
+ product_nickname = SHORT_MODEL_TO_ENUM.get(product_info.model.split(".")[-1]).name
362
+ current_product_data = {
363
+ "Protocol Version": device.pv,
364
+ "Product Nickname": product_nickname,
365
+ "New Feature Info": init_status_result.get("new_feature_info"),
366
+ "New Feature Info Str": init_status_result.get("new_feature_info_str"),
367
+ "Feature Info": init_status_result.get("feature_info"),
368
+ }
369
+
370
+ all_products_data[product_info.model] = current_product_data
371
+
372
+ except Exception as e:
373
+ click.echo(f" - Error processing device {device.name}: {e}", err=True)
374
+ finally:
375
+ await mqtt_client.async_release()
376
+
377
+ if all_products_data:
378
+ click.echo("\n--- Device Information (copy to your YAML file) ---\n")
379
+ # Use yaml.dump to print in a clean, copy-paste friendly format
380
+ click.echo(yaml.dump(all_products_data, sort_keys=False))
381
+
382
+
383
+ @click.command()
384
+ @click.option("--data-file", default="../device_info.yaml", help="Path to the YAML file with device feature data.")
385
+ @click.option("--output-file", default="../SUPPORTED_FEATURES.md", help="Path to the output markdown file.")
386
+ def update_docs(data_file: str, output_file: str):
387
+ """
388
+ Generates a markdown file by processing raw feature data from a YAML file.
389
+ """
390
+ data_path = Path(data_file)
391
+ output_path = Path(output_file)
392
+
393
+ if not data_path.exists():
394
+ click.echo(f"Error: Data file not found at '{data_path}'", err=True)
395
+ return
396
+
397
+ click.echo(f"Loading data from {data_path}...")
398
+ with open(data_path, encoding="utf-8") as f:
399
+ product_data_from_yaml = yaml.safe_load(f)
400
+
401
+ if not product_data_from_yaml:
402
+ click.echo("No data found in YAML file. Exiting.", err=True)
403
+ return
404
+
405
+ product_features_map = {}
406
+ all_feature_names = set()
407
+
408
+ # Process the raw data from YAML to build the feature map
409
+ for model, data in product_data_from_yaml.items():
410
+ # Reconstruct the DeviceFeatures object from the raw data in the YAML file
411
+ device_features = DeviceFeatures.from_feature_flags(
412
+ new_feature_info=data.get("New Feature Info"),
413
+ new_feature_info_str=data.get("New Feature Info Str"),
414
+ feature_info=data.get("Feature Info"),
415
+ product_nickname=data.get("Product Nickname"),
416
+ )
417
+ features_dict = asdict(device_features)
418
+
419
+ # This dictionary will hold the final data for the markdown table row
420
+ current_product_data = {
421
+ "Product Nickname": data.get("Product Nickname", ""),
422
+ "Protocol Version": data.get("Protocol Version", ""),
423
+ "New Feature Info": data.get("New Feature Info", ""),
424
+ "New Feature Info Str": data.get("New Feature Info Str", ""),
425
+ }
426
+
427
+ # Populate features from the calculated DeviceFeatures object
428
+ for feature, is_supported in features_dict.items():
429
+ all_feature_names.add(feature)
430
+ if is_supported:
431
+ current_product_data[feature] = "X"
432
+
433
+ supported_codes = data.get("Feature Info", [])
434
+ if isinstance(supported_codes, list):
435
+ for code in supported_codes:
436
+ feature_name = str(code)
437
+ all_feature_names.add(feature_name)
438
+ current_product_data[feature_name] = "X"
439
+
440
+ product_features_map[model] = current_product_data
441
+
442
+ # --- Helper function to write the markdown table ---
443
+ def write_markdown_table(product_features: dict[str, dict[str, any]], all_features: set[str]):
444
+ """Writes the data into a markdown table (products as columns)."""
445
+ sorted_products = sorted(product_features.keys())
446
+ special_rows = [
447
+ "Product Nickname",
448
+ "Protocol Version",
449
+ "New Feature Info",
450
+ "New Feature Info Str",
451
+ ]
452
+ # Regular features are the remaining keys, sorted alphabetically
453
+ # We filter out the special rows to avoid duplicating them.
454
+ sorted_features = sorted(list(all_features - set(special_rows)))
455
+
456
+ header = ["Feature"] + sorted_products
457
+
458
+ click.echo(f"Writing documentation to {output_path}...")
459
+ with open(output_path, "w", encoding="utf-8") as f:
460
+ f.write("| " + " | ".join(header) + " |\n")
461
+ f.write("|" + "---|" * len(header) + "\n")
462
+
463
+ # Write the special metadata rows first
464
+ for row_name in special_rows:
465
+ row_values = [str(product_features[p].get(row_name, "")) for p in sorted_products]
466
+ f.write("| " + " | ".join([row_name] + row_values) + " |\n")
467
+
468
+ # Write the feature rows
469
+ for feature in sorted_features:
470
+ # Use backticks for feature names that are just numbers (from the list)
471
+ display_feature = f"`{feature}`"
472
+ feature_row = [display_feature]
473
+ for product in sorted_products:
474
+ # Use .get() to place an 'X' or an empty string
475
+ feature_row.append(product_features[product].get(feature, ""))
476
+ f.write("| " + " | ".join(feature_row) + " |\n")
477
+
478
+ write_markdown_table(product_features_map, all_feature_names)
479
+ click.echo("Done.")
480
+
481
+
328
482
  cli.add_command(login)
329
483
  cli.add_command(discover)
330
484
  cli.add_command(list_devices)
@@ -334,6 +488,8 @@ cli.add_command(status)
334
488
  cli.add_command(command)
335
489
  cli.add_command(parser)
336
490
  cli.add_command(session)
491
+ cli.add_command(get_device_info)
492
+ cli.add_command(update_docs)
337
493
 
338
494
 
339
495
  def main():
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  from collections import namedtuple
5
- from enum import Enum, IntEnum
5
+ from enum import Enum, IntEnum, StrEnum
6
6
 
7
7
  _LOGGER = logging.getLogger(__name__)
8
8
  completed_warnings = set()
@@ -51,6 +51,26 @@ class RoborockEnum(IntEnum):
51
51
  return cls.as_dict().items()
52
52
 
53
53
 
54
+ class RoborockModeEnum(StrEnum):
55
+ """A custom StrEnum that also stores an integer code for each member."""
56
+
57
+ code: int
58
+
59
+ def __new__(cls, value: str, code: int) -> RoborockModeEnum:
60
+ """Creates a new enum member."""
61
+ member = str.__new__(cls, value)
62
+ member._value_ = value
63
+ member.code = code
64
+ return member
65
+
66
+ @classmethod
67
+ def from_code(cls, code: int):
68
+ for member in cls:
69
+ if member.code == code:
70
+ return member
71
+ raise ValueError(f"{code} is not a valid code for {cls.__name__}")
72
+
73
+
54
74
  ProductInfo = namedtuple("ProductInfo", ["nickname", "short_models"])
55
75
 
56
76
 
@@ -33,6 +33,7 @@ from .code_mappings import (
33
33
  RoborockFanSpeedSaros10R,
34
34
  RoborockFinishReason,
35
35
  RoborockInCleaning,
36
+ RoborockModeEnum,
36
37
  RoborockMopIntensityCode,
37
38
  RoborockMopIntensityP10,
38
39
  RoborockMopIntensityQ7Max,
@@ -120,6 +121,8 @@ class RoborockBase:
120
121
  return {k: RoborockBase._convert_to_class_obj(value_type, v) for k, v in value.items()}
121
122
  if issubclass(class_type, RoborockBase):
122
123
  return class_type.from_dict(value)
124
+ if issubclass(class_type, RoborockModeEnum):
125
+ return class_type.from_code(value)
123
126
  if class_type is Any:
124
127
  return value
125
128
  return class_type(value) # type: ignore[call-arg]
@@ -4,7 +4,7 @@ from dataclasses import dataclass, field, fields
4
4
  from enum import IntEnum, StrEnum
5
5
  from typing import Any
6
6
 
7
- from roborock import RoborockProductNickname
7
+ from .code_mappings import RoborockProductNickname
8
8
 
9
9
 
10
10
  class NewFeatureStrBit(IntEnum):
@@ -162,8 +162,8 @@ async def create_device_manager(
162
162
  case _:
163
163
  raise NotImplementedError(f"Device {device.name} has unsupported category {product.category}")
164
164
  case DeviceVersion.B01:
165
- mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
166
- traits.append(B01PropsApi(mqtt_channel))
165
+ channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
166
+ traits.append(B01PropsApi(channel))
167
167
  case _:
168
168
  raise NotImplementedError(f"Device {device.name} has unsupported version {device.pv}")
169
169
  return RoborockDevice(device, channel, traits)
@@ -5,6 +5,7 @@ import logging
5
5
  from collections.abc import Callable
6
6
  from dataclasses import dataclass
7
7
 
8
+ from roborock.callbacks import CallbackList, decoder_callback
8
9
  from roborock.exceptions import RoborockConnectionException, RoborockException
9
10
  from roborock.protocol import Decoder, Encoder, create_local_decoder, create_local_encoder
10
11
  from roborock.roborock_message import RoborockMessage
@@ -42,11 +43,13 @@ class LocalChannel(Channel):
42
43
  self._host = host
43
44
  self._transport: asyncio.Transport | None = None
44
45
  self._protocol: _LocalProtocol | None = None
45
- self._subscribers: list[Callable[[RoborockMessage], None]] = []
46
+ self._subscribers: CallbackList[RoborockMessage] = CallbackList(_LOGGER)
46
47
  self._is_connected = False
47
48
 
48
49
  self._decoder: Decoder = create_local_decoder(local_key)
49
50
  self._encoder: Encoder = create_local_encoder(local_key)
51
+ # Callback to decode messages and dispatch to subscribers
52
+ self._data_received: Callable[[bytes], None] = decoder_callback(self._decoder, self._subscribers, _LOGGER)
50
53
 
51
54
  @property
52
55
  def is_connected(self) -> bool:
@@ -76,19 +79,6 @@ class LocalChannel(Channel):
76
79
  self._transport = None
77
80
  self._is_connected = False
78
81
 
79
- def _data_received(self, data: bytes) -> None:
80
- """Handle incoming data from the transport."""
81
- if not (messages := self._decoder(data)):
82
- _LOGGER.warning("Failed to decode local message: %s", data)
83
- return
84
- for message in messages:
85
- _LOGGER.debug("Received message: %s", message)
86
- for callback in self._subscribers:
87
- try:
88
- callback(message)
89
- except Exception as e:
90
- _LOGGER.exception("Uncaught error in message handler callback: %s", e)
91
-
92
82
  def _connection_lost(self, exc: Exception | None) -> None:
93
83
  """Handle connection loss."""
94
84
  _LOGGER.warning("Connection lost to %s", self._host, exc_info=exc)
@@ -97,12 +87,7 @@ class LocalChannel(Channel):
97
87
 
98
88
  async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
99
89
  """Subscribe to all messages from the device."""
100
- self._subscribers.append(callback)
101
-
102
- def unsubscribe() -> None:
103
- self._subscribers.remove(callback)
104
-
105
- return unsubscribe
90
+ return self._subscribers.add_callback(callback)
106
91
 
107
92
  async def publish(self, message: RoborockMessage) -> None:
108
93
  """Send a command message.
@@ -3,6 +3,7 @@
3
3
  import logging
4
4
  from collections.abc import Callable
5
5
 
6
+ from roborock.callbacks import decoder_callback
6
7
  from roborock.containers import HomeDataDevice, RRiot, UserData
7
8
  from roborock.exceptions import RoborockException
8
9
  from roborock.mqtt.session import MqttParams, MqttSession, MqttSessionException
@@ -56,19 +57,8 @@ class MqttChannel(Channel):
56
57
 
57
58
  Returns a callable that can be used to unsubscribe from the topic.
58
59
  """
59
-
60
- def message_handler(payload: bytes) -> None:
61
- if not (messages := self._decoder(payload)):
62
- _LOGGER.warning("Failed to decode MQTT message: %s", payload)
63
- return
64
- for message in messages:
65
- _LOGGER.debug("Received message: %s", message)
66
- try:
67
- callback(message)
68
- except Exception as e:
69
- _LOGGER.exception("Uncaught error in message handler callback: %s", e)
70
-
71
- return await self._mqtt_session.subscribe(self._subscribe_topic, message_handler)
60
+ dispatch = decoder_callback(self._decoder, callback, _LOGGER)
61
+ return await self._mqtt_session.subscribe(self._subscribe_topic, dispatch)
72
62
 
73
63
  async def publish(self, message: RoborockMessage) -> None:
74
64
  """Publish a command message.
@@ -27,4 +27,6 @@ class B01PropsApi(Trait):
27
27
 
28
28
  async def query_values(self, props: list[RoborockB01Props]) -> None:
29
29
  """Query the device for the values of the given Dyad protocols."""
30
- await send_decoded_command(self._channel, dps=10000, command=RoborockB01Methods.GET_PROP, params=props)
30
+ await send_decoded_command(
31
+ self._channel, dps=10000, command=RoborockB01Methods.GET_PROP, params={"property": props}
32
+ )
@@ -17,6 +17,8 @@ from contextlib import asynccontextmanager
17
17
  import aiomqtt
18
18
  from aiomqtt import MqttError, TLSParameters
19
19
 
20
+ from roborock.callbacks import CallbackMap
21
+
20
22
  from .session import MqttParams, MqttSession, MqttSessionException
21
23
 
22
24
  _LOGGER = logging.getLogger(__name__)
@@ -53,7 +55,7 @@ class RoborockMqttSession(MqttSession):
53
55
  self._backoff = MIN_BACKOFF_INTERVAL
54
56
  self._client: aiomqtt.Client | None = None
55
57
  self._client_lock = asyncio.Lock()
56
- self._listeners: dict[str, list[Callable[[bytes], None]]] = {}
58
+ self._listeners: CallbackMap[str, bytes] = CallbackMap(_LOGGER)
57
59
 
58
60
  @property
59
61
  def connected(self) -> bool:
@@ -164,7 +166,7 @@ class RoborockMqttSession(MqttSession):
164
166
  # Re-establish any existing subscriptions
165
167
  async with self._client_lock:
166
168
  self._client = client
167
- for topic in self._listeners:
169
+ for topic in self._listeners.keys():
168
170
  _LOGGER.debug("Re-establishing subscription to topic %s", topic)
169
171
  # TODO: If this fails it will break the whole connection. Make
170
172
  # this retry again in the background with backoff.
@@ -179,13 +181,7 @@ class RoborockMqttSession(MqttSession):
179
181
  _LOGGER.debug("Processing MQTT messages")
180
182
  async for message in client.messages:
181
183
  _LOGGER.debug("Received message: %s", message)
182
- for listener in self._listeners.get(message.topic.value, []):
183
- try:
184
- listener(message.payload)
185
- except asyncio.CancelledError:
186
- raise
187
- except Exception as e:
188
- _LOGGER.exception("Uncaught exception in subscriber callback: %s", e)
184
+ self._listeners(message.topic.value, message.payload)
189
185
 
190
186
  async def subscribe(self, topic: str, callback: Callable[[bytes], None]) -> Callable[[], None]:
191
187
  """Subscribe to messages on the specified topic and invoke the callback for new messages.
@@ -196,9 +192,7 @@ class RoborockMqttSession(MqttSession):
196
192
  The returned callable unsubscribes from the topic when called.
197
193
  """
198
194
  _LOGGER.debug("Subscribing to topic %s", topic)
199
- if topic not in self._listeners:
200
- self._listeners[topic] = []
201
- self._listeners[topic].append(callback)
195
+ unsub = self._listeners.add_callback(topic, callback)
202
196
 
203
197
  async with self._client_lock:
204
198
  if self._client:
@@ -210,7 +204,7 @@ class RoborockMqttSession(MqttSession):
210
204
  else:
211
205
  _LOGGER.debug("Client not connected, will establish subscription later")
212
206
 
213
- return lambda: self._listeners[topic].remove(callback)
207
+ return unsub
214
208
 
215
209
  async def publish(self, topic: str, message: bytes) -> None:
216
210
  """Publish a message on the topic."""
@@ -13,6 +13,7 @@ from roborock.roborock_message import (
13
13
  RoborockMessage,
14
14
  RoborockMessageProtocol,
15
15
  )
16
+ from roborock.util import get_next_int
16
17
 
17
18
  _LOGGER = logging.getLogger(__name__)
18
19
 
@@ -23,7 +24,15 @@ ParamsType = list | dict | int | None
23
24
 
24
25
  def encode_mqtt_payload(dps: int, command: CommandType, params: ParamsType) -> RoborockMessage:
25
26
  """Encode payload for B01 commands over MQTT."""
26
- dps_data = {"dps": {dps: {"method": command, "params": params or []}}}
27
+ dps_data = {
28
+ "dps": {
29
+ dps: {
30
+ "method": str(command),
31
+ "msgId": str(get_next_int(100000000000, 999999999999)),
32
+ "params": params or [],
33
+ }
34
+ }
35
+ }
27
36
  payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size)
28
37
  return RoborockMessage(
29
38
  protocol=RoborockMessageProtocol.RPC_REQUEST,