python-roborock 2.27.0__tar.gz → 2.28.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 (42) hide show
  1. {python_roborock-2.27.0 → python_roborock-2.28.0}/PKG-INFO +1 -1
  2. {python_roborock-2.27.0 → python_roborock-2.28.0}/pyproject.toml +1 -1
  3. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/code_mappings.py +63 -0
  4. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/containers.py +8 -138
  5. python_roborock-2.28.0/roborock/device_features.py +365 -0
  6. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/devices/device.py +1 -1
  7. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/devices/local_channel.py +6 -1
  8. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/devices/mqtt_channel.py +1 -1
  9. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/devices/v1_channel.py +18 -59
  10. python_roborock-2.28.0/roborock/devices/v1_rpc_channel.py +148 -0
  11. {python_roborock-2.27.0 → python_roborock-2.28.0}/LICENSE +0 -0
  12. {python_roborock-2.27.0 → python_roborock-2.28.0}/README.md +0 -0
  13. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/__init__.py +0 -0
  14. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/api.py +0 -0
  15. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/cli.py +0 -0
  16. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/cloud_api.py +0 -0
  17. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/command_cache.py +0 -0
  18. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/const.py +0 -0
  19. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/devices/README.md +0 -0
  20. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/devices/__init__.py +0 -0
  21. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/devices/device_manager.py +0 -0
  22. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/exceptions.py +0 -0
  23. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/local_api.py +0 -0
  24. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/mqtt/__init__.py +0 -0
  25. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/mqtt/roborock_session.py +0 -0
  26. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/mqtt/session.py +0 -0
  27. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/protocol.py +0 -0
  28. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/protocols/a01_protocol.py +0 -0
  29. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/protocols/v1_protocol.py +0 -0
  30. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/py.typed +0 -0
  31. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/roborock_future.py +0 -0
  32. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/roborock_message.py +0 -0
  33. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/roborock_typing.py +0 -0
  34. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/util.py +0 -0
  35. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/version_1_apis/__init__.py +0 -0
  36. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  37. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  38. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  39. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/version_a01_apis/__init__.py +0 -0
  40. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  41. {python_roborock-2.27.0 → python_roborock-2.28.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
  42. {python_roborock-2.27.0 → python_roborock-2.28.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.27.0
3
+ Version: 2.28.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.27.0"
3
+ version = "2.28.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,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ from collections import namedtuple
4
5
  from enum import Enum, IntEnum
5
6
 
6
7
  _LOGGER = logging.getLogger(__name__)
@@ -50,6 +51,68 @@ class RoborockEnum(IntEnum):
50
51
  return cls.as_dict().items()
51
52
 
52
53
 
54
+ ProductInfo = namedtuple("ProductInfo", ["nickname", "short_models"])
55
+
56
+
57
+ class RoborockProductNickname(Enum):
58
+ # Coral Series
59
+ CORAL = ProductInfo(nickname="Coral", short_models=("a20", "a21"))
60
+ CORALPRO = ProductInfo(nickname="CoralPro", short_models=("a143", "a144"))
61
+
62
+ # Pearl Series
63
+ PEARL = ProductInfo(nickname="Pearl", short_models=("a74", "a75"))
64
+ PEARLC = ProductInfo(nickname="PearlC", short_models=("a103", "a104"))
65
+ PEARLE = ProductInfo(nickname="PearlE", short_models=("a167", "a168"))
66
+ PEARLELITE = ProductInfo(nickname="PearlELite", short_models=("a169", "a170"))
67
+ PEARLPLUS = ProductInfo(nickname="PearlPlus", short_models=("a86", "a87"))
68
+ PEARLPLUSS = ProductInfo(nickname="PearlPlusS", short_models=("a116", "a117", "a136"))
69
+ PEARLS = ProductInfo(nickname="PearlS", short_models=("a100", "a101"))
70
+ PEARLSLITE = ProductInfo(nickname="PearlSLite", short_models=("a122", "a123"))
71
+
72
+ # Ruby Series
73
+ RUBYPLUS = ProductInfo(nickname="RubyPlus", short_models=("t4", "s4"))
74
+ RUBYSC = ProductInfo(nickname="RubySC", short_models=("p5", "a08"))
75
+ RUBYSE = ProductInfo(nickname="RubySE", short_models=("a19",))
76
+ RUBYSLITE = ProductInfo(nickname="RubySLite", short_models=("p6", "s5e", "a05"))
77
+
78
+ # Tanos Series
79
+ TANOS = ProductInfo(nickname="Tanos", short_models=("t6", "s6"))
80
+ TANOSE = ProductInfo(nickname="TanosE", short_models=("t7", "a11"))
81
+ TANOSS = ProductInfo(nickname="TanosS", short_models=("a14", "a15"))
82
+ TANOSSC = ProductInfo(nickname="TanosSC", short_models=("a39", "a40"))
83
+ TANOSSE = ProductInfo(nickname="TanosSE", short_models=("a33", "a34"))
84
+ TANOSSMAX = ProductInfo(nickname="TanosSMax", short_models=("a52",))
85
+ TANOSSLITE = ProductInfo(nickname="TanosSLite", short_models=("a37", "a38"))
86
+ TANOSSPLUS = ProductInfo(nickname="TanosSPlus", short_models=("a23", "a24"))
87
+ TANOSV = ProductInfo(nickname="TanosV", short_models=("t7p", "a09", "a10"))
88
+
89
+ # Topaz Series
90
+ TOPAZS = ProductInfo(nickname="TopazS", short_models=("a29", "a30", "a76"))
91
+ TOPAZSC = ProductInfo(nickname="TopazSC", short_models=("a64", "a65"))
92
+ TOPAZSPLUS = ProductInfo(nickname="TopazSPlus", short_models=("a46", "a47", "a66"))
93
+ TOPAZSPOWER = ProductInfo(nickname="TopazSPower", short_models=("a62",))
94
+ TOPAZSV = ProductInfo(nickname="TopazSV", short_models=("a26", "a27"))
95
+
96
+ # Ultron Series
97
+ ULTRON = ProductInfo(nickname="Ultron", short_models=("a50", "a51"))
98
+ ULTRONE = ProductInfo(nickname="UltronE", short_models=("a72", "a84"))
99
+ ULTRONLITE = ProductInfo(nickname="UltronLite", short_models=("a73", "a85"))
100
+ ULTRONSC = ProductInfo(nickname="UltronSC", short_models=("a94", "a95"))
101
+ ULTRONSE = ProductInfo(nickname="UltronSE", short_models=("a124", "a125", "a139", "a140"))
102
+ ULTRONSPLUS = ProductInfo(nickname="UltronSPlus", short_models=("a68", "a69", "a70"))
103
+ ULTRONSV = ProductInfo(nickname="UltronSV", short_models=("a96", "a97"))
104
+
105
+ # Verdelite Series
106
+ VERDELITE = ProductInfo(nickname="Verdelite", short_models=("a146", "a147"))
107
+
108
+ # Vivian Series
109
+ VIVIAN = ProductInfo(nickname="Vivian", short_models=("a134", "a135", "a155", "a156"))
110
+ VIVIANC = ProductInfo(nickname="VivianC", short_models=("a158", "a159"))
111
+
112
+
113
+ SHORT_MODEL_TO_ENUM = {model: product for product in RoborockProductNickname for model in product.value.short_models}
114
+
115
+
53
116
  class RoborockStateCode(RoborockEnum):
54
117
  unknown = 0
55
118
  starting = 1
@@ -11,6 +11,7 @@ from functools import cached_property
11
11
  from typing import Any, NamedTuple, get_args, get_origin
12
12
 
13
13
  from .code_mappings import (
14
+ SHORT_MODEL_TO_ENUM,
14
15
  RoborockCategory,
15
16
  RoborockCleanType,
16
17
  RoborockDockDustCollectionModeCode,
@@ -53,6 +54,7 @@ from .code_mappings import (
53
54
  RoborockMopModeS8ProUltra,
54
55
  RoborockMopModeSaros10,
55
56
  RoborockMopModeSaros10R,
57
+ RoborockProductNickname,
56
58
  RoborockStartType,
57
59
  RoborockStateCode,
58
60
  )
@@ -87,6 +89,7 @@ from .const import (
87
89
  STRAINER_REPLACE_TIME,
88
90
  ROBOROCK_G20S_Ultra,
89
91
  )
92
+ from .device_features import DeviceFeatures
90
93
  from .exceptions import RoborockException
91
94
 
92
95
  _LOGGER = logging.getLogger(__name__)
@@ -306,144 +309,6 @@ class HomeDataDevice(RoborockBase):
306
309
  silent_ota_switch: bool | None = None
307
310
  setting: Any | None = None
308
311
  f: bool | None = None
309
- device_features: DeviceFeatures | None = None
310
-
311
- # seemingly not just str like I thought - example: '0000000000002000' and '0000000000002F63'
312
-
313
- # def __post_init__(self):
314
- # if self.feature_set is not None and self.new_feature_set is not None and self.new_feature_set != "":
315
- # self.device_features = build_device_features(self.feature_set, self.new_feature_set)
316
-
317
-
318
- @dataclass
319
- class DeviceFeatures(RoborockBase):
320
- map_carpet_add_supported: bool
321
- show_clean_finish_reason_supported: bool
322
- resegment_supported: bool
323
- video_monitor_supported: bool
324
- any_state_transit_goto_supported: bool
325
- fw_filter_obstacle_supported: bool
326
- video_setting_supported: bool
327
- ignore_unknown_map_object_supported: bool
328
- set_child_supported: bool
329
- carpet_supported: bool
330
- mop_path_supported: bool
331
- multi_map_segment_timer_supported: bool
332
- custom_water_box_distance_supported: bool
333
- wash_then_charge_cmd_supported: bool
334
- room_name_supported: bool
335
- current_map_restore_enabled: bool
336
- photo_upload_supported: bool
337
- shake_mop_set_supported: bool
338
- map_beautify_internal_debug_supported: bool
339
- new_data_for_clean_history: bool
340
- new_data_for_clean_history_detail: bool
341
- flow_led_setting_supported: bool
342
- dust_collection_setting_supported: bool
343
- rpc_retry_supported: bool
344
- avoid_collision_supported: bool
345
- support_set_switch_map_mode: bool
346
- support_smart_scene: bool
347
- support_floor_edit: bool
348
- support_furniture: bool
349
- support_room_tag: bool
350
- support_quick_map_builder: bool
351
- support_smart_global_clean_with_custom_mode: bool
352
- record_allowed: bool
353
- careful_slow_map_supported: bool
354
- egg_mode_supported: bool
355
- unsave_map_reason_supported: bool
356
- carpet_show_on_map: bool
357
- supported_valley_electricity: bool
358
- drying_supported: bool
359
- download_test_voice_supported: bool
360
- support_backup_map: bool
361
- support_custom_mode_in_cleaning: bool
362
- support_remote_control_in_call: bool
363
- support_set_volume_in_call: bool
364
- support_clean_estimate: bool
365
- support_custom_dnd: bool
366
- carpet_deep_clean_supported: bool
367
- stuck_zone_supported: bool
368
- custom_door_sill_supported: bool
369
- clean_route_fast_mode_supported: bool
370
- cliff_zone_supported: bool
371
- smart_door_sill_supported: bool
372
- support_floor_direction: bool
373
- wifi_manage_supported: bool
374
- back_charge_auto_wash_supported: bool
375
- support_incremental_map: bool
376
- offline_map_supported: bool
377
-
378
-
379
- def build_device_features(feature_set: str, new_feature_set: str) -> DeviceFeatures:
380
- new_feature_set_int = int(new_feature_set)
381
- feature_set_int = int(feature_set)
382
- new_feature_set_divided = int(new_feature_set_int / (2**32))
383
- # Convert last 8 digits of new feature set into hexadecimal number
384
- converted_new_feature_set = int("0x" + new_feature_set[-8:], 16)
385
- new_feature_set_mod_8: bool = len(new_feature_set) % 8 == 0
386
- return DeviceFeatures(
387
- map_carpet_add_supported=bool(1073741824 & new_feature_set_int),
388
- show_clean_finish_reason_supported=bool(1 & new_feature_set_int),
389
- resegment_supported=bool(4 & new_feature_set_int),
390
- video_monitor_supported=bool(8 & new_feature_set_int),
391
- any_state_transit_goto_supported=bool(16 & new_feature_set_int),
392
- fw_filter_obstacle_supported=bool(32 & new_feature_set_int),
393
- video_setting_supported=bool(64 & new_feature_set_int),
394
- ignore_unknown_map_object_supported=bool(128 & new_feature_set_int),
395
- set_child_supported=bool(256 & new_feature_set_int),
396
- carpet_supported=bool(512 & new_feature_set_int),
397
- mop_path_supported=bool(2048 & new_feature_set_int),
398
- multi_map_segment_timer_supported=bool(feature_set_int and 4096 & new_feature_set_int),
399
- custom_water_box_distance_supported=bool(new_feature_set_int and 2147483648 & new_feature_set_int),
400
- wash_then_charge_cmd_supported=bool((new_feature_set_divided >> 5) & 1),
401
- room_name_supported=bool(16384 & new_feature_set_int),
402
- current_map_restore_enabled=bool(8192 & new_feature_set_int),
403
- photo_upload_supported=bool(65536 & new_feature_set_int),
404
- shake_mop_set_supported=bool(262144 & new_feature_set_int),
405
- map_beautify_internal_debug_supported=bool(2097152 & new_feature_set_int),
406
- new_data_for_clean_history=bool(4194304 & new_feature_set_int),
407
- new_data_for_clean_history_detail=bool(8388608 & new_feature_set_int),
408
- flow_led_setting_supported=bool(16777216 & new_feature_set_int),
409
- dust_collection_setting_supported=bool(33554432 & new_feature_set_int),
410
- rpc_retry_supported=bool(67108864 & new_feature_set_int),
411
- avoid_collision_supported=bool(134217728 & new_feature_set_int),
412
- support_set_switch_map_mode=bool(268435456 & new_feature_set_int),
413
- support_smart_scene=bool(new_feature_set_divided & 2),
414
- support_floor_edit=bool(new_feature_set_divided & 8),
415
- support_furniture=bool((new_feature_set_divided >> 4) & 1),
416
- support_room_tag=bool((new_feature_set_divided >> 6) & 1),
417
- support_quick_map_builder=bool((new_feature_set_divided >> 7) & 1),
418
- support_smart_global_clean_with_custom_mode=bool((new_feature_set_divided >> 8) & 1),
419
- record_allowed=bool(1024 & new_feature_set_int),
420
- careful_slow_map_supported=bool((new_feature_set_divided >> 9) & 1),
421
- egg_mode_supported=bool((new_feature_set_divided >> 10) & 1),
422
- unsave_map_reason_supported=bool((new_feature_set_divided >> 14) & 1),
423
- carpet_show_on_map=bool((new_feature_set_divided >> 12) & 1),
424
- supported_valley_electricity=bool((new_feature_set_divided >> 13) & 1),
425
- # This one could actually be incorrect
426
- # ((t.robotNewFeatures / 2 ** 32) >> 15) & 1 && (module422.DMM.isTopazSV_CE || 'cn' == t.deviceLocation));
427
- drying_supported=bool((new_feature_set_divided >> 15) & 1),
428
- download_test_voice_supported=bool((new_feature_set_divided >> 16) & 1),
429
- support_backup_map=bool((new_feature_set_divided >> 17) & 1),
430
- support_custom_mode_in_cleaning=bool((new_feature_set_divided >> 18) & 1),
431
- support_remote_control_in_call=bool((new_feature_set_divided >> 19) & 1),
432
- support_set_volume_in_call=new_feature_set_mod_8 and bool(1 & converted_new_feature_set),
433
- support_clean_estimate=new_feature_set_mod_8 and bool(2 & converted_new_feature_set),
434
- support_custom_dnd=new_feature_set_mod_8 and bool(4 & converted_new_feature_set),
435
- carpet_deep_clean_supported=bool(8 & converted_new_feature_set),
436
- stuck_zone_supported=new_feature_set_mod_8 and bool(16 & converted_new_feature_set),
437
- custom_door_sill_supported=new_feature_set_mod_8 and bool(32 & converted_new_feature_set),
438
- clean_route_fast_mode_supported=bool(256 & converted_new_feature_set),
439
- cliff_zone_supported=new_feature_set_mod_8 and bool(512 & converted_new_feature_set),
440
- smart_door_sill_supported=new_feature_set_mod_8 and bool(1024 & converted_new_feature_set),
441
- support_floor_direction=new_feature_set_mod_8 and bool(2048 & converted_new_feature_set),
442
- wifi_manage_supported=bool(128 & converted_new_feature_set),
443
- back_charge_auto_wash_supported=bool(4096 & converted_new_feature_set),
444
- support_incremental_map=bool(8192 & converted_new_feature_set),
445
- offline_map_supported=bool(16384 & converted_new_feature_set),
446
- )
447
312
 
448
313
 
449
314
  @dataclass
@@ -890,6 +755,11 @@ class DeviceData(RoborockBase):
890
755
  device: HomeDataDevice
891
756
  model: str
892
757
  host: str | None = None
758
+ product_nickname: RoborockProductNickname | None = None
759
+ device_features: DeviceFeatures | None = None
760
+
761
+ def __post_init__(self):
762
+ self.product_nickname = SHORT_MODEL_TO_ENUM.get(self.model.split(".")[-1], RoborockProductNickname.PEARLPLUS)
893
763
 
894
764
 
895
765
  @dataclass
@@ -0,0 +1,365 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field, fields
4
+ from enum import IntEnum
5
+ from typing import Any
6
+
7
+ from roborock import RoborockProductNickname
8
+
9
+
10
+ class NewFeatureStrBit(IntEnum):
11
+ TWO_KEY_REAL_TIME_VIDEO = 32
12
+ TWO_KEY_RTV_IN_CHARGING = 33
13
+ DIRTY_REPLENISH_CLEAN = 34
14
+ AUTO_DELIVERY_FIELD_IN_GLOBAL_STATUS = 35
15
+ AVOID_COLLISION_MODE = 36
16
+ VOICE_CONTROL = 37
17
+ NEW_ENDPOINT = 38
18
+ PUMPING_WATER = 39
19
+ CORNER_MOP_STRETCH = 40
20
+ HOT_WASH_TOWEL = 41
21
+ FLOOR_DIR_CLEAN_ANY_TIME = 42
22
+ PET_SUPPLIES_DEEP_CLEAN = 43
23
+ MOP_SHAKE_WATER_MAX = 45
24
+ EXACT_CUSTOM_MODE = 47
25
+ VIDEO_PATROL = 48
26
+ CARPET_CUSTOM_CLEAN = 49
27
+ PET_SNAPSHOT = 50
28
+ CUSTOM_CLEAN_MODE_COUNT = 51
29
+ NEW_AI_RECOGNITION = 52
30
+ AUTO_COLLECTION_2 = 53
31
+ RIGHT_BRUSH_STRETCH = 54
32
+ SMART_CLEAN_MODE_SET = 55
33
+ DIRTY_OBJECT_DETECT = 56
34
+ NO_NEED_CARPET_PRESS_SET = 57
35
+ VOICE_CONTROL_LED = 58
36
+ WATER_LEAK_CHECK = 60
37
+ MIN_BATTERY_15_TO_CLEAN_TASK = 62
38
+ GAP_DEEP_CLEAN = 63
39
+ OBJECT_DETECT_CHECK = 64
40
+ IDENTIFY_ROOM = 66
41
+ MATTER = 67
42
+ WORKDAY_HOLIDAY = 69
43
+ CLEAN_DIRECT_STATUS = 70
44
+ MAP_ERASER = 71
45
+ OPTIMIZE_BATTERY = 72
46
+ ACTIVATE_VIDEO_CHARGING_AND_STANDBY = 73
47
+ CARPET_LONG_HAIRED = 75
48
+ CLEAN_HISTORY_TIME_LINE = 76
49
+ MAX_ZONE_OPENED = 77
50
+ EXHIBITION_FUNCTION = 78
51
+ LDS_LIFTING = 79
52
+ AUTO_TEAR_DOWN_MOP = 80
53
+ SMALL_SIDE_MOP = 81
54
+ SUPPORT_SIDE_BRUSH_UP_DOWN = 82
55
+ DRY_INTERVAL_TIMER = 83
56
+ UVC_STERILIZE = 84
57
+ MIDWAY_BACK_TO_DOCK = 85
58
+ SUPPORT_MAIN_BRUSH_UP_DOWN = 86
59
+ EGG_DANCE_MODE = 87
60
+
61
+
62
+ @dataclass
63
+ class DeviceFeatures:
64
+ """Represents the features supported by a Roborock device."""
65
+
66
+ # Features from robot_new_features (lower 32 bits)
67
+ is_show_clean_finish_reason_supported: bool = field(metadata={"robot_new_features": 1})
68
+ is_re_segment_supported: bool = field(metadata={"robot_new_features": 4})
69
+ is_video_monitor_supported: bool = field(metadata={"robot_new_features": 8})
70
+ is_any_state_transit_goto_supported: bool = field(metadata={"robot_new_features": 16})
71
+ is_fw_filter_obstacle_supported: bool = field(metadata={"robot_new_features": 32})
72
+ is_video_setting_supported: bool = field(metadata={"robot_new_features": 64})
73
+ is_ignore_unknown_map_object_supported: bool = field(metadata={"robot_new_features": 128})
74
+ is_set_child_supported: bool = field(metadata={"robot_new_features": 256})
75
+ is_carpet_supported: bool = field(metadata={"robot_new_features": 512})
76
+ is_record_allowed: bool = field(metadata={"robot_new_features": 1024})
77
+ is_mop_path_supported: bool = field(metadata={"robot_new_features": 2048})
78
+ is_multi_map_segment_timer_supported: bool = field(metadata={"robot_new_features": 4096})
79
+ is_current_map_restore_enabled: bool = field(metadata={"robot_new_features": 8192})
80
+ is_room_name_supported: bool = field(metadata={"robot_new_features": 16384})
81
+ is_shake_mop_set_supported: bool = field(metadata={"robot_new_features": 262144})
82
+ is_map_beautify_internal_debug_supported: bool = field(metadata={"robot_new_features": 2097152})
83
+ is_new_data_for_clean_history: bool = field(metadata={"robot_new_features": 4194304})
84
+ is_new_data_for_clean_history_detail: bool = field(metadata={"robot_new_features": 8388608})
85
+ is_flow_led_setting_supported: bool = field(metadata={"robot_new_features": 16777216})
86
+ is_dust_collection_setting_supported: bool = field(metadata={"robot_new_features": 33554432})
87
+ is_rpc_retry_supported: bool = field(metadata={"robot_new_features": 67108864})
88
+ is_avoid_collision_supported: bool = field(metadata={"robot_new_features": 134217728})
89
+ is_support_set_switch_map_mode: bool = field(metadata={"robot_new_features": 268435456})
90
+ is_map_carpet_add_support: bool = field(metadata={"robot_new_features": 1073741824})
91
+ is_custom_water_box_distance_supported: bool = field(metadata={"robot_new_features": 2147483648})
92
+
93
+ # Features from robot_new_features (upper 32 bits)
94
+ is_support_smart_scene: bool = field(metadata={"upper_32_bits": 1})
95
+ is_support_floor_edit: bool = field(metadata={"upper_32_bits": 3})
96
+ is_support_furniture: bool = field(metadata={"upper_32_bits": 4})
97
+ is_wash_then_charge_cmd_supported: bool = field(metadata={"upper_32_bits": 5})
98
+ is_support_room_tag: bool = field(metadata={"upper_32_bits": 6})
99
+ is_support_quick_map_builder: bool = field(metadata={"upper_32_bits": 7})
100
+ is_support_smart_global_clean_with_custom_mode: bool = field(metadata={"upper_32_bits": 8})
101
+ is_careful_slow_mop_supported: bool = field(metadata={"upper_32_bits": 9})
102
+ is_egg_mode_supported_from_new_features: bool = field(metadata={"upper_32_bits": 10})
103
+ is_carpet_show_on_map: bool = field(metadata={"upper_32_bits": 12})
104
+ is_supported_valley_electricity: bool = field(metadata={"upper_32_bits": 13})
105
+ is_unsave_map_reason_supported: bool = field(metadata={"upper_32_bits": 14})
106
+ is_supported_drying: bool = field(metadata={"upper_32_bits": 15})
107
+ is_supported_download_test_voice: bool = field(metadata={"upper_32_bits": 16})
108
+ is_support_backup_map: bool = field(metadata={"upper_32_bits": 17})
109
+ is_support_custom_mode_in_cleaning: bool = field(metadata={"upper_32_bits": 18})
110
+ is_support_remote_control_in_call: bool = field(metadata={"upper_32_bits": 19})
111
+
112
+ # Features from new_feature_info_str (masking last 8 chars / 32 bits)
113
+ is_support_set_volume_in_call: bool = field(metadata={"new_feature_str_mask": (1, 8)})
114
+ is_support_clean_estimate: bool = field(metadata={"new_feature_str_mask": (2, 8)})
115
+ is_support_custom_dnd: bool = field(metadata={"new_feature_str_mask": (4, 8)})
116
+ is_carpet_deep_clean_supported: bool = field(metadata={"new_feature_str_mask": (8, 8)})
117
+ is_support_stuck_zone: bool = field(metadata={"new_feature_str_mask": (16, 8)})
118
+ is_support_custom_door_sill: bool = field(metadata={"new_feature_str_mask": (32, 8)})
119
+ is_wifi_manage_supported: bool = field(metadata={"new_feature_str_mask": (128, 8)})
120
+ is_clean_route_fast_mode_supported: bool = field(metadata={"new_feature_str_mask": (256, 8)})
121
+ is_support_cliff_zone: bool = field(metadata={"new_feature_str_mask": (512, 8)})
122
+ is_support_smart_door_sill: bool = field(metadata={"new_feature_str_mask": (1024, 8)})
123
+ is_support_floor_direction: bool = field(metadata={"new_feature_str_mask": (2048, 8)})
124
+ is_back_charge_auto_wash_supported: bool = field(metadata={"new_feature_str_mask": (4096, 8)})
125
+ is_support_incremental_map: bool = field(metadata={"new_feature_str_mask": (4194304, 8)})
126
+ is_offline_map_supported: bool = field(metadata={"new_feature_str_mask": (16384, 8)})
127
+ is_super_deep_wash_supported: bool = field(metadata={"new_feature_str_mask": (32768, 8)})
128
+ is_ces2022_supported: bool = field(metadata={"new_feature_str_mask": (65536, 8)})
129
+ is_dss_believable: bool = field(metadata={"new_feature_str_mask": (131072, 8)})
130
+ is_main_brush_up_down_supported_from_str: bool = field(metadata={"new_feature_str_mask": (262144, 8)})
131
+ is_goto_pure_clean_path_supported: bool = field(metadata={"new_feature_str_mask": (524288, 8)})
132
+ is_water_up_down_drain_supported: bool = field(metadata={"new_feature_str_mask": (1048576, 8)})
133
+ is_setting_carpet_first_supported: bool = field(metadata={"new_feature_str_mask": (8388608, 8)})
134
+ is_clean_route_deep_slow_plus_supported: bool = field(metadata={"new_feature_str_mask": (16777216, 8)})
135
+ is_dynamically_skip_clean_zone_supported: bool = field(metadata={"new_feature_str_mask": (33554432, 8)})
136
+ is_dynamically_add_clean_zones_supported: bool = field(metadata={"new_feature_str_mask": (67108864, 8)})
137
+ is_left_water_drain_supported: bool = field(metadata={"new_feature_str_mask": (134217728, 8)})
138
+ is_clean_count_setting_supported: bool = field(metadata={"new_feature_str_mask": (1073741824, 8)})
139
+ is_corner_clean_mode_supported: bool = field(metadata={"new_feature_str_mask": (2147483648, 8)})
140
+
141
+ # Features from new_feature_info_str (by bit index)
142
+ is_two_key_real_time_video_supported: bool = field(
143
+ metadata={"new_feature_str_bit": NewFeatureStrBit.TWO_KEY_REAL_TIME_VIDEO}
144
+ )
145
+ is_two_key_rtv_in_charging_supported: bool = field(
146
+ metadata={"new_feature_str_bit": NewFeatureStrBit.TWO_KEY_RTV_IN_CHARGING}
147
+ )
148
+ is_dirty_replenish_clean_supported: bool = field(
149
+ metadata={"new_feature_str_bit": NewFeatureStrBit.DIRTY_REPLENISH_CLEAN}
150
+ )
151
+ is_auto_delivery_field_in_global_status_supported: bool = field(
152
+ metadata={"new_feature_str_bit": NewFeatureStrBit.AUTO_DELIVERY_FIELD_IN_GLOBAL_STATUS}
153
+ )
154
+ is_avoid_collision_mode_supported: bool = field(
155
+ metadata={"new_feature_str_bit": NewFeatureStrBit.AVOID_COLLISION_MODE}
156
+ )
157
+ is_voice_control_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.VOICE_CONTROL})
158
+ is_new_endpoint_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.NEW_ENDPOINT})
159
+ is_pumping_water_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.PUMPING_WATER})
160
+ is_corner_mop_stretch_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.CORNER_MOP_STRETCH})
161
+ is_hot_wash_towel_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.HOT_WASH_TOWEL})
162
+ is_floor_dir_clean_any_time_supported: bool = field(
163
+ metadata={"new_feature_str_bit": NewFeatureStrBit.FLOOR_DIR_CLEAN_ANY_TIME}
164
+ )
165
+ is_pet_supplies_deep_clean_supported: bool = field(
166
+ metadata={"new_feature_str_bit": NewFeatureStrBit.PET_SUPPLIES_DEEP_CLEAN}
167
+ )
168
+ is_mop_shake_water_max_supported: bool = field(
169
+ metadata={"new_feature_str_bit": NewFeatureStrBit.MOP_SHAKE_WATER_MAX}
170
+ )
171
+ is_exact_custom_mode_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.EXACT_CUSTOM_MODE})
172
+ is_video_patrol_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.VIDEO_PATROL})
173
+ is_carpet_custom_clean_supported: bool = field(
174
+ metadata={"new_feature_str_bit": NewFeatureStrBit.CARPET_CUSTOM_CLEAN}
175
+ )
176
+ is_pet_snapshot_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.PET_SNAPSHOT})
177
+ is_custom_clean_mode_count_supported: bool = field(
178
+ metadata={"new_feature_str_bit": NewFeatureStrBit.CUSTOM_CLEAN_MODE_COUNT}
179
+ )
180
+ is_new_ai_recognition_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.NEW_AI_RECOGNITION})
181
+ is_auto_collection_2_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.AUTO_COLLECTION_2})
182
+ is_right_brush_stretch_supported: bool = field(
183
+ metadata={"new_feature_str_bit": NewFeatureStrBit.RIGHT_BRUSH_STRETCH}
184
+ )
185
+ is_smart_clean_mode_set_supported: bool = field(
186
+ metadata={"new_feature_str_bit": NewFeatureStrBit.SMART_CLEAN_MODE_SET}
187
+ )
188
+ is_dirty_object_detect_supported: bool = field(
189
+ metadata={"new_feature_str_bit": NewFeatureStrBit.DIRTY_OBJECT_DETECT}
190
+ )
191
+ is_no_need_carpet_press_set_supported: bool = field(
192
+ metadata={"new_feature_str_bit": NewFeatureStrBit.NO_NEED_CARPET_PRESS_SET}
193
+ )
194
+ is_voice_control_led_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.VOICE_CONTROL_LED})
195
+ is_water_leak_check_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.WATER_LEAK_CHECK})
196
+ is_min_battery_15_to_clean_task_supported: bool = field(
197
+ metadata={"new_feature_str_bit": NewFeatureStrBit.MIN_BATTERY_15_TO_CLEAN_TASK}
198
+ )
199
+ is_gap_deep_clean_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.GAP_DEEP_CLEAN})
200
+ is_object_detect_check_supported: bool = field(
201
+ metadata={"new_feature_str_bit": NewFeatureStrBit.OBJECT_DETECT_CHECK}
202
+ )
203
+ is_identify_room_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.IDENTIFY_ROOM})
204
+ is_matter_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.MATTER})
205
+ is_workday_holiday_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.WORKDAY_HOLIDAY})
206
+ is_clean_direct_status_supported: bool = field(
207
+ metadata={"new_feature_str_bit": NewFeatureStrBit.CLEAN_DIRECT_STATUS}
208
+ )
209
+ is_map_eraser_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.MAP_ERASER})
210
+ is_optimize_battery_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.OPTIMIZE_BATTERY})
211
+ is_activate_video_charging_and_standby_supported: bool = field(
212
+ metadata={"new_feature_str_bit": NewFeatureStrBit.ACTIVATE_VIDEO_CHARGING_AND_STANDBY}
213
+ )
214
+ is_carpet_long_haired_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.CARPET_LONG_HAIRED})
215
+ is_clean_history_time_line_supported: bool = field(
216
+ metadata={"new_feature_str_bit": NewFeatureStrBit.CLEAN_HISTORY_TIME_LINE}
217
+ )
218
+ is_max_zone_opened_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.MAX_ZONE_OPENED})
219
+ is_exhibition_function_supported: bool = field(
220
+ metadata={"new_feature_str_bit": NewFeatureStrBit.EXHIBITION_FUNCTION}
221
+ )
222
+ is_lds_lifting_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.LDS_LIFTING})
223
+ is_auto_tear_down_mop_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.AUTO_TEAR_DOWN_MOP})
224
+ is_small_side_mop_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.SMALL_SIDE_MOP})
225
+ is_support_side_brush_up_down_supported: bool = field(
226
+ metadata={"new_feature_str_bit": NewFeatureStrBit.SUPPORT_SIDE_BRUSH_UP_DOWN}
227
+ )
228
+ is_dry_interval_timer_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.DRY_INTERVAL_TIMER})
229
+ is_uvc_sterilize_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.UVC_STERILIZE})
230
+ is_midway_back_to_dock_supported: bool = field(
231
+ metadata={"new_feature_str_bit": NewFeatureStrBit.MIDWAY_BACK_TO_DOCK}
232
+ )
233
+ is_support_main_brush_up_down_supported: bool = field(
234
+ metadata={"new_feature_str_bit": NewFeatureStrBit.SUPPORT_MAIN_BRUSH_UP_DOWN}
235
+ )
236
+ is_egg_dance_mode_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.EGG_DANCE_MODE})
237
+
238
+ # Features from feature_info list
239
+ is_led_status_switch_supported: bool = field(metadata={"robot_features": 119})
240
+ is_multi_floor_supported: bool = field(metadata={"robot_features": 120})
241
+ is_support_fetch_timer_summary: bool = field(metadata={"robot_features": 122})
242
+ is_order_clean_supported: bool = field(metadata={"robot_features": 123})
243
+ is_analysis_supported: bool = field(metadata={"robot_features": 124})
244
+ is_remote_supported: bool = field(metadata={"robot_features": 125})
245
+ is_support_voice_control_debug: bool = field(metadata={"robot_features": 130})
246
+
247
+ # Features from model whitelists/blacklists or other flags
248
+ is_mop_forbidden_supported: bool = field(
249
+ metadata={
250
+ "model_whitelist": [
251
+ RoborockProductNickname.TANOSV,
252
+ RoborockProductNickname.TOPAZSV,
253
+ RoborockProductNickname.TANOS,
254
+ RoborockProductNickname.TANOSE,
255
+ RoborockProductNickname.TANOSSLITE,
256
+ RoborockProductNickname.TANOSS,
257
+ RoborockProductNickname.TANOSSPLUS,
258
+ RoborockProductNickname.TANOSSMAX,
259
+ RoborockProductNickname.ULTRON,
260
+ RoborockProductNickname.ULTRONLITE,
261
+ RoborockProductNickname.PEARL,
262
+ RoborockProductNickname.RUBYSLITE,
263
+ ]
264
+ }
265
+ )
266
+ is_soft_clean_mode_supported: bool = field(
267
+ metadata={
268
+ "model_whitelist": [
269
+ RoborockProductNickname.TANOSV,
270
+ RoborockProductNickname.TANOSE,
271
+ RoborockProductNickname.TANOS,
272
+ ]
273
+ }
274
+ )
275
+ is_custom_mode_supported: bool = field(metadata={"model_blacklist": [RoborockProductNickname.TANOS]})
276
+ is_support_custom_carpet: bool = field(metadata={"model_whitelist": [RoborockProductNickname.ULTRONLITE]})
277
+ is_show_general_obstacle_supported: bool = field(metadata={"model_whitelist": [RoborockProductNickname.TANOSSPLUS]})
278
+ is_show_obstacle_photo_supported: bool = field(
279
+ metadata={
280
+ "model_whitelist": [
281
+ RoborockProductNickname.TANOSSPLUS,
282
+ RoborockProductNickname.TANOSSMAX,
283
+ RoborockProductNickname.ULTRON,
284
+ ]
285
+ }
286
+ )
287
+ is_rubber_brush_carpet_supported: bool = field(metadata={"model_whitelist": [RoborockProductNickname.ULTRONLITE]})
288
+ is_carpet_pressure_use_origin_paras_supported: bool = field(
289
+ metadata={"model_whitelist": [RoborockProductNickname.ULTRONLITE]}
290
+ )
291
+ is_support_mop_back_pwm_set: bool = field(metadata={"model_whitelist": [RoborockProductNickname.PEARL]})
292
+ is_collect_dust_mode_supported: bool = field(metadata={"model_blacklist": [RoborockProductNickname.PEARL]})
293
+
294
+ @classmethod
295
+ def from_feature_flags(
296
+ cls,
297
+ new_feature_info: int,
298
+ new_feature_info_str: str,
299
+ feature_info: list[int],
300
+ product_nickname: RoborockProductNickname | None,
301
+ ) -> DeviceFeatures:
302
+ """Creates a DeviceFeatures instance from raw feature flags.
303
+ :param new_feature_info: A int from get_init_status (sometimes can be found in homedata, but it is not always)
304
+ :param new_feature_info_str: A hex string from get_init_status or home_data.
305
+ :param feature_info: A list of ints from get_init_status
306
+ :param product_nickname: The product nickname of the device."""
307
+ # For any future reverse engineerining:
308
+ # RobotNewFeatures = new_feature_info
309
+ # newFeatureInfoStr = new_feature_info_str
310
+ # feature_info =robotFeatures
311
+ kwargs: dict[str, Any] = {}
312
+
313
+ for f in fields(cls):
314
+ # Default all features to False.
315
+ kwargs[f.name] = False
316
+ if not f.metadata:
317
+ continue
318
+
319
+ if (mask := f.metadata.get("robot_new_features")) is not None:
320
+ kwargs[f.name] = bool(mask & new_feature_info)
321
+ elif (bit_index := f.metadata.get("upper_32_bits")) is not None:
322
+ # Check bits in the upper 32-bit integer of new_feature_info
323
+ if new_feature_info:
324
+ kwargs[f.name] = bool(((new_feature_info >> 32) >> bit_index) & 1)
325
+ elif (mask_info := f.metadata.get("new_feature_str_mask")) is not None:
326
+ # Check bitmask against a slice of the hex string
327
+ if new_feature_info_str:
328
+ try:
329
+ mask, slice_count = mask_info
330
+ if len(new_feature_info_str) >= slice_count:
331
+ last_chars = new_feature_info_str[-slice_count:]
332
+ value = int(last_chars, 16)
333
+ kwargs[f.name] = bool(mask & value)
334
+ except (ValueError, IndexError):
335
+ pass # Keep it False
336
+ elif (bit := f.metadata.get("new_feature_str_bit")) is not None:
337
+ # Check a specific bit in the hex string using its index
338
+ if new_feature_info_str:
339
+ try:
340
+ # Bit index defines which character and which bit inside it to check
341
+ char_index_from_end = 1 + bit.value // 4
342
+ if char_index_from_end <= len(new_feature_info_str):
343
+ char_hex = new_feature_info_str[-char_index_from_end]
344
+ nibble = int(char_hex, 16)
345
+ bit_in_nibble = bit.value % 4
346
+ kwargs[f.name] = bool((nibble >> bit_in_nibble) & 1)
347
+ except (ValueError, IndexError):
348
+ pass # Keep it False
349
+ elif (feature_id := f.metadata.get("robot_features")) is not None:
350
+ kwargs[f.name] = feature_id in feature_info
351
+ elif (whitelist := f.metadata.get("model_whitelist")) is not None:
352
+ # If product_nickname is None, assume it is not in the whitelist
353
+ kwargs[f.name] = product_nickname in whitelist or product_nickname is None
354
+ elif (blacklist := f.metadata.get("model_blacklist")) is not None:
355
+ # If product_nickname is None, assume it is not in the blacklist.
356
+ if product_nickname is None:
357
+ kwargs[f.name] = True
358
+ else:
359
+ kwargs[f.name] = product_nickname not in blacklist
360
+
361
+ return cls(**kwargs)
362
+
363
+ def get_supported_features(self) -> list[str]:
364
+ """Returns a list of supported features (Primarily used for logging purposes)."""
365
+ return [k for k, v in vars(self).items() if v]
@@ -117,4 +117,4 @@ class RoborockDevice:
117
117
  This is a placeholder command and will likely be changed/moved in the future.
118
118
  """
119
119
  status_type: type[Status] = ModelStatus.get(self._product_info.model, S7MaxVStatus)
120
- return await self._v1_channel.send_decoded_command(RoborockCommand.GET_STATUS, response_type=status_type)
120
+ return await self._v1_channel.rpc_channel.send_command(RoborockCommand.GET_STATUS, response_type=status_type)
@@ -50,6 +50,11 @@ class LocalChannel:
50
50
  self._encoder: Encoder = create_local_encoder(local_key)
51
51
  self._queue_lock = asyncio.Lock()
52
52
 
53
+ @property
54
+ def is_connected(self) -> bool:
55
+ """Check if the channel is currently connected."""
56
+ return self._is_connected
57
+
53
58
  async def connect(self) -> None:
54
59
  """Connect to the device."""
55
60
  if self._is_connected:
@@ -113,7 +118,7 @@ class LocalChannel:
113
118
  else:
114
119
  _LOGGER.debug("Received message with no waiting handler: request_id=%s", request_id)
115
120
 
116
- async def send_command(self, message: RoborockMessage, timeout: float = 10.0) -> RoborockMessage:
121
+ async def send_message(self, message: RoborockMessage, timeout: float = 10.0) -> RoborockMessage:
117
122
  """Send a command message and wait for the response message."""
118
123
  if not self._transport or not self._is_connected:
119
124
  raise RoborockConnectionException("Not connected to device")
@@ -80,7 +80,7 @@ class MqttChannel:
80
80
  else:
81
81
  _LOGGER.debug("Received message with no waiting handler: request_id=%s", request_id)
82
82
 
83
- async def send_command(self, message: RoborockMessage, timeout: float = 10.0) -> RoborockMessage:
83
+ async def send_message(self, message: RoborockMessage, timeout: float = 10.0) -> RoborockMessage:
84
84
  """Send a command message and wait for the response message.
85
85
 
86
86
  Returns the raw response message - caller is responsible for parsing.
@@ -6,25 +6,21 @@ handling both MQTT and local connections with automatic fallback.
6
6
 
7
7
  import logging
8
8
  from collections.abc import Callable
9
- from typing import Any, TypeVar
9
+ from typing import TypeVar
10
10
 
11
11
  from roborock.containers import HomeDataDevice, NetworkInfo, RoborockBase, UserData
12
12
  from roborock.exceptions import RoborockException
13
13
  from roborock.mqtt.session import MqttParams, MqttSession
14
14
  from roborock.protocols.v1_protocol import (
15
- CommandType,
16
- ParamsType,
17
15
  SecurityData,
18
- create_mqtt_payload_encoder,
19
16
  create_security_data,
20
- decode_rpc_response,
21
- encode_local_payload,
22
17
  )
23
18
  from roborock.roborock_message import RoborockMessage
24
19
  from roborock.roborock_typing import RoborockCommand
25
20
 
26
21
  from .local_channel import LocalChannel, LocalSession, create_local_session
27
22
  from .mqtt_channel import MqttChannel
23
+ from .v1_rpc_channel import V1RpcChannel, create_combined_rpc_channel, create_mqtt_rpc_channel
28
24
 
29
25
  _LOGGER = logging.getLogger(__name__)
30
26
 
@@ -58,9 +54,10 @@ class V1Channel:
58
54
  """
59
55
  self._device_uid = device_uid
60
56
  self._mqtt_channel = mqtt_channel
61
- self._mqtt_payload_encoder = create_mqtt_payload_encoder(security_data)
57
+ self._mqtt_rpc_channel = create_mqtt_rpc_channel(mqtt_channel, security_data)
62
58
  self._local_session = local_session
63
59
  self._local_channel: LocalChannel | None = None
60
+ self._combined_rpc_channel: V1RpcChannel | None = None
64
61
  self._mqtt_unsub: Callable[[], None] | None = None
65
62
  self._local_unsub: Callable[[], None] | None = None
66
63
  self._callback: Callable[[RoborockMessage], None] | None = None
@@ -76,6 +73,16 @@ class V1Channel:
76
73
  """Return whether MQTT connection is available."""
77
74
  return self._mqtt_unsub is not None
78
75
 
76
+ @property
77
+ def rpc_channel(self) -> V1RpcChannel:
78
+ """Return the combined RPC channel prefers local with a fallback to MQTT."""
79
+ return self._combined_rpc_channel or self._mqtt_rpc_channel
80
+
81
+ @property
82
+ def mqtt_rpc_channel(self) -> V1RpcChannel:
83
+ """Return the MQTT RPC channel."""
84
+ return self._mqtt_rpc_channel
85
+
79
86
  async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
80
87
  """Subscribe to all messages from the device.
81
88
 
@@ -119,7 +126,9 @@ class V1Channel:
119
126
  This is a cloud only command used to get the local device's IP address.
120
127
  """
121
128
  try:
122
- return await self._send_mqtt_decoded_command(RoborockCommand.GET_NETWORK_INFO, response_type=NetworkInfo)
129
+ return await self._mqtt_rpc_channel.send_command(
130
+ RoborockCommand.GET_NETWORK_INFO, response_type=NetworkInfo
131
+ )
123
132
  except RoborockException as e:
124
133
  raise RoborockException(f"Network info failed for device {self._device_uid}") from e
125
134
 
@@ -136,59 +145,9 @@ class V1Channel:
136
145
  except RoborockException as e:
137
146
  self._local_channel = None
138
147
  raise RoborockException(f"Error connecting to local device {self._device_uid}: {e}") from e
139
-
148
+ self._combined_rpc_channel = create_combined_rpc_channel(self._local_channel, self._mqtt_rpc_channel)
140
149
  return await self._local_channel.subscribe(self._on_local_message)
141
150
 
142
- async def send_decoded_command(
143
- self,
144
- method: CommandType,
145
- *,
146
- response_type: type[_T],
147
- params: ParamsType = None,
148
- ) -> _T:
149
- """Send a command using the best available transport.
150
-
151
- Will prefer local connection if available, falling back to MQTT.
152
- """
153
- connection = "local" if self.is_local_connected else "mqtt"
154
- _LOGGER.debug("Sending command (%s): %s, params=%s", connection, method, params)
155
- if self._local_channel:
156
- return await self._send_local_decoded_command(method, response_type=response_type, params=params)
157
- return await self._send_mqtt_decoded_command(method, response_type=response_type, params=params)
158
-
159
- async def _send_mqtt_raw_command(self, method: CommandType, params: ParamsType | None = None) -> dict[str, Any]:
160
- """Send a raw command and return a raw unparsed response."""
161
- message = self._mqtt_payload_encoder(method, params)
162
- _LOGGER.debug("Sending MQTT message for device %s: %s", self._device_uid, message)
163
- response = await self._mqtt_channel.send_command(message)
164
- return decode_rpc_response(response)
165
-
166
- async def _send_mqtt_decoded_command(
167
- self, method: CommandType, *, response_type: type[_T], params: ParamsType | None = None
168
- ) -> _T:
169
- """Send a command over MQTT and decode the response."""
170
- decoded_response = await self._send_mqtt_raw_command(method, params)
171
- return response_type.from_dict(decoded_response)
172
-
173
- async def _send_local_raw_command(self, method: CommandType, params: ParamsType | None = None) -> dict[str, Any]:
174
- """Send a raw command over local connection."""
175
- if not self._local_channel:
176
- raise RoborockException("Local channel is not connected")
177
-
178
- message = encode_local_payload(method, params)
179
- _LOGGER.debug("Sending local message for device %s: %s", self._device_uid, message)
180
- response = await self._local_channel.send_command(message)
181
- return decode_rpc_response(response)
182
-
183
- async def _send_local_decoded_command(
184
- self, method: CommandType, *, response_type: type[_T], params: ParamsType | None = None
185
- ) -> _T:
186
- """Send a command over local connection and decode the response."""
187
- if not self._local_channel:
188
- raise RoborockException("Local channel is not connected")
189
- decoded_response = await self._send_local_raw_command(method, params)
190
- return response_type.from_dict(decoded_response)
191
-
192
151
  def _on_mqtt_message(self, message: RoborockMessage) -> None:
193
152
  """Handle incoming MQTT messages."""
194
153
  _LOGGER.debug("V1Channel received MQTT message from device %s: %s", self._device_uid, message)
@@ -0,0 +1,148 @@
1
+ """V1 Rpc Channel for Roborock devices.
2
+
3
+ This is a wrapper around the V1 channel that provides a higher level interface
4
+ for sending typed commands and receiving typed responses. This also provides
5
+ a simple interface for sending commands and receiving responses over both MQTT
6
+ and local connections, preferring local when available.
7
+ """
8
+
9
+ import logging
10
+ from collections.abc import Callable
11
+ from typing import Any, Protocol, TypeVar, overload
12
+
13
+ from roborock.containers import RoborockBase
14
+ from roborock.protocols.v1_protocol import (
15
+ CommandType,
16
+ ParamsType,
17
+ SecurityData,
18
+ create_mqtt_payload_encoder,
19
+ decode_rpc_response,
20
+ encode_local_payload,
21
+ )
22
+ from roborock.roborock_message import RoborockMessage
23
+
24
+ from .local_channel import LocalChannel
25
+ from .mqtt_channel import MqttChannel
26
+
27
+ _LOGGER = logging.getLogger(__name__)
28
+
29
+
30
+ _T = TypeVar("_T", bound=RoborockBase)
31
+
32
+
33
+ class V1RpcChannel(Protocol):
34
+ """Protocol for V1 RPC channels.
35
+
36
+ This is a wrapper around a raw channel that provides a high-level interface
37
+ for sending commands and receiving responses.
38
+ """
39
+
40
+ @overload
41
+ async def send_command(
42
+ self,
43
+ method: CommandType,
44
+ *,
45
+ params: ParamsType = None,
46
+ ) -> Any:
47
+ """Send a command and return a decoded response."""
48
+ ...
49
+
50
+ @overload
51
+ async def send_command(
52
+ self,
53
+ method: CommandType,
54
+ *,
55
+ response_type: type[_T],
56
+ params: ParamsType = None,
57
+ ) -> _T:
58
+ """Send a command and return a parsed response RoborockBase type."""
59
+ ...
60
+
61
+
62
+ class BaseV1RpcChannel(V1RpcChannel):
63
+ """Base implementation that provides the typed response logic."""
64
+
65
+ async def send_command(
66
+ self,
67
+ method: CommandType,
68
+ *,
69
+ response_type: type[_T] | None = None,
70
+ params: ParamsType = None,
71
+ ) -> _T | Any:
72
+ """Send a command and return either a decoded or parsed response."""
73
+ decoded_response = await self._send_raw_command(method, params=params)
74
+
75
+ if response_type is not None:
76
+ return response_type.from_dict(decoded_response)
77
+ return decoded_response
78
+
79
+ async def _send_raw_command(
80
+ self,
81
+ method: CommandType,
82
+ *,
83
+ params: ParamsType = None,
84
+ ) -> Any:
85
+ """Send a raw command and return the decoded response. Must be implemented by subclasses."""
86
+ raise NotImplementedError
87
+
88
+
89
+ class CombinedV1RpcChannel(BaseV1RpcChannel):
90
+ """A V1 RPC channel that can use both local and MQTT channels, preferring local when available."""
91
+
92
+ def __init__(
93
+ self, local_channel: LocalChannel, local_rpc_channel: V1RpcChannel, mqtt_channel: V1RpcChannel
94
+ ) -> None:
95
+ """Initialize the combined channel with local and MQTT channels."""
96
+ self._local_channel = local_channel
97
+ self._local_rpc_channel = local_rpc_channel
98
+ self._mqtt_rpc_channel = mqtt_channel
99
+
100
+ async def _send_raw_command(
101
+ self,
102
+ method: CommandType,
103
+ *,
104
+ params: ParamsType = None,
105
+ ) -> Any:
106
+ """Send a command and return a parsed response RoborockBase type."""
107
+ if self._local_channel.is_connected:
108
+ return await self._local_rpc_channel.send_command(method, params=params)
109
+ return await self._mqtt_rpc_channel.send_command(method, params=params)
110
+
111
+
112
+ class PayloadEncodedV1RpcChannel(BaseV1RpcChannel):
113
+ """Protocol for V1 channels that send encoded commands."""
114
+
115
+ def __init__(
116
+ self,
117
+ name: str,
118
+ channel: MqttChannel | LocalChannel,
119
+ payload_encoder: Callable[[CommandType, ParamsType], RoborockMessage],
120
+ ) -> None:
121
+ """Initialize the channel with a raw channel and an encoder function."""
122
+ self._name = name
123
+ self._channel = channel
124
+ self._payload_encoder = payload_encoder
125
+
126
+ async def _send_raw_command(
127
+ self,
128
+ method: CommandType,
129
+ *,
130
+ params: ParamsType = None,
131
+ ) -> Any:
132
+ """Send a command and return a parsed response RoborockBase type."""
133
+ _LOGGER.debug("Sending command (%s): %s, params=%s", self._name, method, params)
134
+ message = self._payload_encoder(method, params)
135
+ response = await self._channel.send_message(message)
136
+ return decode_rpc_response(response)
137
+
138
+
139
+ def create_mqtt_rpc_channel(mqtt_channel: MqttChannel, security_data: SecurityData) -> V1RpcChannel:
140
+ """Create a V1 RPC channel using an MQTT channel."""
141
+ payload_encoder = create_mqtt_payload_encoder(security_data)
142
+ return PayloadEncodedV1RpcChannel("mqtt", mqtt_channel, payload_encoder)
143
+
144
+
145
+ def create_combined_rpc_channel(local_channel: LocalChannel, mqtt_rpc_channel: V1RpcChannel) -> V1RpcChannel:
146
+ """Create a V1 RPC channel that combines local and MQTT channels."""
147
+ local_rpc_channel = PayloadEncodedV1RpcChannel("local", local_channel, encode_local_payload)
148
+ return CombinedV1RpcChannel(local_channel, local_rpc_channel, mqtt_rpc_channel)