roborock-cli 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. roborock_cli/__init__.py +3 -0
  2. roborock_cli/__main__.py +76 -0
  3. roborock_cli/_vendor/VERSION +6 -0
  4. roborock_cli/_vendor/__init__.py +0 -0
  5. roborock_cli/_vendor/roborock/__init__.py +27 -0
  6. roborock_cli/_vendor/roborock/broadcast_protocol.py +114 -0
  7. roborock_cli/_vendor/roborock/callbacks.py +130 -0
  8. roborock_cli/_vendor/roborock/cli.py +1338 -0
  9. roborock_cli/_vendor/roborock/const.py +84 -0
  10. roborock_cli/_vendor/roborock/data/__init__.py +9 -0
  11. roborock_cli/_vendor/roborock/data/b01_q10/__init__.py +2 -0
  12. roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_code_mappings.py +213 -0
  13. roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_containers.py +102 -0
  14. roborock_cli/_vendor/roborock/data/b01_q7/__init__.py +2 -0
  15. roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_code_mappings.py +303 -0
  16. roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_containers.py +302 -0
  17. roborock_cli/_vendor/roborock/data/code_mappings.py +198 -0
  18. roborock_cli/_vendor/roborock/data/containers.py +530 -0
  19. roborock_cli/_vendor/roborock/data/dyad/__init__.py +2 -0
  20. roborock_cli/_vendor/roborock/data/dyad/dyad_code_mappings.py +102 -0
  21. roborock_cli/_vendor/roborock/data/dyad/dyad_containers.py +28 -0
  22. roborock_cli/_vendor/roborock/data/v1/__init__.py +3 -0
  23. roborock_cli/_vendor/roborock/data/v1/v1_clean_modes.py +192 -0
  24. roborock_cli/_vendor/roborock/data/v1/v1_code_mappings.py +644 -0
  25. roborock_cli/_vendor/roborock/data/v1/v1_containers.py +800 -0
  26. roborock_cli/_vendor/roborock/data/zeo/__init__.py +2 -0
  27. roborock_cli/_vendor/roborock/data/zeo/zeo_code_mappings.py +138 -0
  28. roborock_cli/_vendor/roborock/data/zeo/zeo_containers.py +0 -0
  29. roborock_cli/_vendor/roborock/device_features.py +668 -0
  30. roborock_cli/_vendor/roborock/devices/README.md +41 -0
  31. roborock_cli/_vendor/roborock/devices/__init__.py +11 -0
  32. roborock_cli/_vendor/roborock/devices/cache.py +143 -0
  33. roborock_cli/_vendor/roborock/devices/device.py +240 -0
  34. roborock_cli/_vendor/roborock/devices/device_manager.py +269 -0
  35. roborock_cli/_vendor/roborock/devices/file_cache.py +79 -0
  36. roborock_cli/_vendor/roborock/devices/rpc/__init__.py +14 -0
  37. roborock_cli/_vendor/roborock/devices/rpc/a01_channel.py +94 -0
  38. roborock_cli/_vendor/roborock/devices/rpc/b01_q10_channel.py +57 -0
  39. roborock_cli/_vendor/roborock/devices/rpc/b01_q7_channel.py +101 -0
  40. roborock_cli/_vendor/roborock/devices/rpc/v1_channel.py +457 -0
  41. roborock_cli/_vendor/roborock/devices/traits/__init__.py +28 -0
  42. roborock_cli/_vendor/roborock/devices/traits/a01/__init__.py +191 -0
  43. roborock_cli/_vendor/roborock/devices/traits/b01/__init__.py +12 -0
  44. roborock_cli/_vendor/roborock/devices/traits/b01/q10/__init__.py +76 -0
  45. roborock_cli/_vendor/roborock/devices/traits/b01/q10/command.py +32 -0
  46. roborock_cli/_vendor/roborock/devices/traits/b01/q10/common.py +115 -0
  47. roborock_cli/_vendor/roborock/devices/traits/b01/q10/status.py +32 -0
  48. roborock_cli/_vendor/roborock/devices/traits/b01/q10/vacuum.py +81 -0
  49. roborock_cli/_vendor/roborock/devices/traits/b01/q7/__init__.py +136 -0
  50. roborock_cli/_vendor/roborock/devices/traits/b01/q7/clean_summary.py +75 -0
  51. roborock_cli/_vendor/roborock/devices/traits/traits_mixin.py +64 -0
  52. roborock_cli/_vendor/roborock/devices/traits/v1/__init__.py +344 -0
  53. roborock_cli/_vendor/roborock/devices/traits/v1/child_lock.py +29 -0
  54. roborock_cli/_vendor/roborock/devices/traits/v1/clean_summary.py +83 -0
  55. roborock_cli/_vendor/roborock/devices/traits/v1/command.py +38 -0
  56. roborock_cli/_vendor/roborock/devices/traits/v1/common.py +172 -0
  57. roborock_cli/_vendor/roborock/devices/traits/v1/consumeable.py +48 -0
  58. roborock_cli/_vendor/roborock/devices/traits/v1/device_features.py +74 -0
  59. roborock_cli/_vendor/roborock/devices/traits/v1/do_not_disturb.py +41 -0
  60. roborock_cli/_vendor/roborock/devices/traits/v1/dust_collection_mode.py +13 -0
  61. roborock_cli/_vendor/roborock/devices/traits/v1/flow_led_status.py +29 -0
  62. roborock_cli/_vendor/roborock/devices/traits/v1/home.py +285 -0
  63. roborock_cli/_vendor/roborock/devices/traits/v1/led_status.py +43 -0
  64. roborock_cli/_vendor/roborock/devices/traits/v1/map_content.py +83 -0
  65. roborock_cli/_vendor/roborock/devices/traits/v1/maps.py +80 -0
  66. roborock_cli/_vendor/roborock/devices/traits/v1/network_info.py +55 -0
  67. roborock_cli/_vendor/roborock/devices/traits/v1/rooms.py +105 -0
  68. roborock_cli/_vendor/roborock/devices/traits/v1/routines.py +26 -0
  69. roborock_cli/_vendor/roborock/devices/traits/v1/smart_wash_params.py +13 -0
  70. roborock_cli/_vendor/roborock/devices/traits/v1/status.py +101 -0
  71. roborock_cli/_vendor/roborock/devices/traits/v1/valley_electricity_timer.py +44 -0
  72. roborock_cli/_vendor/roborock/devices/traits/v1/volume.py +27 -0
  73. roborock_cli/_vendor/roborock/devices/traits/v1/wash_towel_mode.py +13 -0
  74. roborock_cli/_vendor/roborock/devices/transport/__init__.py +8 -0
  75. roborock_cli/_vendor/roborock/devices/transport/channel.py +32 -0
  76. roborock_cli/_vendor/roborock/devices/transport/local_channel.py +295 -0
  77. roborock_cli/_vendor/roborock/devices/transport/mqtt_channel.py +118 -0
  78. roborock_cli/_vendor/roborock/diagnostics.py +166 -0
  79. roborock_cli/_vendor/roborock/exceptions.py +95 -0
  80. roborock_cli/_vendor/roborock/map/__init__.py +7 -0
  81. roborock_cli/_vendor/roborock/map/map_parser.py +123 -0
  82. roborock_cli/_vendor/roborock/mqtt/__init__.py +10 -0
  83. roborock_cli/_vendor/roborock/mqtt/health_manager.py +60 -0
  84. roborock_cli/_vendor/roborock/mqtt/roborock_session.py +463 -0
  85. roborock_cli/_vendor/roborock/mqtt/session.py +108 -0
  86. roborock_cli/_vendor/roborock/protocol.py +558 -0
  87. roborock_cli/_vendor/roborock/protocols/__init__.py +3 -0
  88. roborock_cli/_vendor/roborock/protocols/a01_protocol.py +74 -0
  89. roborock_cli/_vendor/roborock/protocols/b01_q10_protocol.py +87 -0
  90. roborock_cli/_vendor/roborock/protocols/b01_q7_protocol.py +81 -0
  91. roborock_cli/_vendor/roborock/protocols/v1_protocol.py +271 -0
  92. roborock_cli/_vendor/roborock/py.typed +0 -0
  93. roborock_cli/_vendor/roborock/roborock_message.py +246 -0
  94. roborock_cli/_vendor/roborock/roborock_typing.py +382 -0
  95. roborock_cli/_vendor/roborock/util.py +54 -0
  96. roborock_cli/_vendor/roborock/web_api.py +761 -0
  97. roborock_cli/cli.py +715 -0
  98. roborock_cli/connection.py +202 -0
  99. roborock_cli/helpers.py +71 -0
  100. roborock_cli/server.py +759 -0
  101. roborock_cli/setup_auth.py +92 -0
  102. roborock_cli-0.1.1.dist-info/METADATA +172 -0
  103. roborock_cli-0.1.1.dist-info/RECORD +106 -0
  104. roborock_cli-0.1.1.dist-info/WHEEL +4 -0
  105. roborock_cli-0.1.1.dist-info/entry_points.txt +2 -0
  106. roborock_cli-0.1.1.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,48 @@
1
+ """Trait for managing consumable attributes.
2
+
3
+ A consumable attribute is one that is expected to be replaced or refilled
4
+ periodically, such as filters, brushes, etc.
5
+ """
6
+
7
+ from enum import StrEnum
8
+ from typing import Self
9
+
10
+ from roborock_cli._vendor.roborock.data import Consumable
11
+ from roborock_cli._vendor.roborock.devices.traits.v1 import common
12
+ from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
13
+
14
+ __all__ = [
15
+ "ConsumableTrait",
16
+ ]
17
+
18
+
19
+ class ConsumableAttribute(StrEnum):
20
+ """Enum for consumable attributes."""
21
+
22
+ SENSOR_DIRTY_TIME = "sensor_dirty_time"
23
+ FILTER_WORK_TIME = "filter_work_time"
24
+ SIDE_BRUSH_WORK_TIME = "side_brush_work_time"
25
+ MAIN_BRUSH_WORK_TIME = "main_brush_work_time"
26
+
27
+ @classmethod
28
+ def from_str(cls, value: str) -> Self:
29
+ """Create a ConsumableAttribute from a string value."""
30
+ for member in cls:
31
+ if member.value == value:
32
+ return member
33
+ raise ValueError(f"Unknown ConsumableAttribute: {value}")
34
+
35
+
36
+ class ConsumableTrait(Consumable, common.V1TraitMixin):
37
+ """Trait for managing consumable attributes on Roborock devices.
38
+
39
+ After the first refresh, you can tell what consumables are supported by
40
+ checking which attributes are not None.
41
+ """
42
+
43
+ command = RoborockCommand.GET_CONSUMABLE
44
+
45
+ async def reset_consumable(self, consumable: ConsumableAttribute) -> None:
46
+ """Reset a specific consumable attribute on the device."""
47
+ await self.rpc_channel.send_command(RoborockCommand.RESET_CONSUMABLE, params=[consumable.value])
48
+ await self.refresh()
@@ -0,0 +1,74 @@
1
+ from dataclasses import Field, fields
2
+
3
+ from roborock_cli._vendor.roborock.data import AppInitStatus, HomeDataProduct, RoborockBase
4
+ from roborock_cli._vendor.roborock.data.v1.v1_containers import FieldNameBase
5
+ from roborock_cli._vendor.roborock.device_features import DeviceFeatures
6
+ from roborock_cli._vendor.roborock.devices.cache import DeviceCache
7
+ from roborock_cli._vendor.roborock.devices.traits.v1 import common
8
+ from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
9
+
10
+
11
+ class DeviceFeaturesTrait(DeviceFeatures, common.V1TraitMixin):
12
+ """Trait for managing supported features on Roborock devices."""
13
+
14
+ command = RoborockCommand.APP_GET_INIT_STATUS
15
+
16
+ def __init__(self, product: HomeDataProduct, device_cache: DeviceCache) -> None: # pylint: disable=super-init-not-called
17
+ """Initialize DeviceFeaturesTrait."""
18
+ self._product = product
19
+ self._nickname = product.product_nickname
20
+ self._device_cache = device_cache
21
+ # All fields of DeviceFeatures are required. Initialize them to False
22
+ # so we have some known state.
23
+ for field in fields(self):
24
+ setattr(self, field.name, False)
25
+
26
+ def is_field_supported(self, cls: type[RoborockBase], field_name: FieldNameBase) -> bool:
27
+ """Determines if the specified field is supported by this device.
28
+
29
+ We use dataclass attributes on the field to specify the schema code that is required
30
+ for the field to be supported and it is compared against the list of
31
+ supported schema codes for the device returned in the product information.
32
+ """
33
+ dataclass_field: Field | None = None
34
+ for field in fields(cls):
35
+ if field.name == field_name:
36
+ dataclass_field = field
37
+ break
38
+ if dataclass_field is None:
39
+ raise ValueError(f"Field {field_name} not found in {cls}")
40
+
41
+ requires_schema_code = dataclass_field.metadata.get("requires_schema_code", None)
42
+ if requires_schema_code is None:
43
+ # We assume the field is supported
44
+ return True
45
+ # If the field requires a protocol that is not supported, we return False
46
+ return requires_schema_code in self._product.supported_schema_codes
47
+
48
+ async def refresh(self) -> None:
49
+ """Refresh the contents of this trait.
50
+
51
+ This will use cached device features if available since they do not
52
+ change often and this avoids unnecessary RPC calls. This would only
53
+ ever change with a firmware update, so caching is appropriate.
54
+ """
55
+ cache_data = await self._device_cache.get()
56
+ if cache_data.device_features is not None:
57
+ self._update_trait_values(cache_data.device_features)
58
+ return
59
+ # Save cached device features
60
+ await super().refresh()
61
+ cache_data.device_features = self
62
+ await self._device_cache.set(cache_data)
63
+
64
+ def _parse_response(self, response: common.V1ResponseData) -> DeviceFeatures:
65
+ """Parse the response from the device into a MapContentTrait instance."""
66
+ if not isinstance(response, list):
67
+ raise ValueError(f"Unexpected AppInitStatus response format: {type(response)}")
68
+ app_status = AppInitStatus.from_dict(response[0])
69
+ return DeviceFeatures.from_feature_flags(
70
+ new_feature_info=app_status.new_feature_info,
71
+ new_feature_info_str=app_status.new_feature_info_str,
72
+ feature_info=app_status.feature_info,
73
+ product_nickname=self._nickname,
74
+ )
@@ -0,0 +1,41 @@
1
+ from roborock_cli._vendor.roborock.data import DnDTimer
2
+ from roborock_cli._vendor.roborock.devices.traits.v1 import common
3
+ from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
4
+
5
+ _ENABLED_PARAM = "enabled"
6
+
7
+
8
+ class DoNotDisturbTrait(DnDTimer, common.V1TraitMixin, common.RoborockSwitchBase):
9
+ """Trait for managing Do Not Disturb (DND) settings on Roborock devices."""
10
+
11
+ command = RoborockCommand.GET_DND_TIMER
12
+
13
+ @property
14
+ def is_on(self) -> bool:
15
+ """Return whether the Do Not Disturb (DND) timer is enabled."""
16
+ return self.enabled == 1
17
+
18
+ async def set_dnd_timer(self, dnd_timer: DnDTimer) -> None:
19
+ """Set the Do Not Disturb (DND) timer settings of the device."""
20
+ await self.rpc_channel.send_command(RoborockCommand.SET_DND_TIMER, params=dnd_timer.as_list())
21
+ await self.refresh()
22
+
23
+ async def clear_dnd_timer(self) -> None:
24
+ """Clear the Do Not Disturb (DND) timer settings of the device."""
25
+ await self.rpc_channel.send_command(RoborockCommand.CLOSE_DND_TIMER)
26
+ await self.refresh()
27
+
28
+ async def enable(self) -> None:
29
+ """Set the Do Not Disturb (DND) timer settings of the device."""
30
+ await self.rpc_channel.send_command(
31
+ RoborockCommand.SET_DND_TIMER,
32
+ params=self.as_list(),
33
+ )
34
+ # Optimistic update to avoid an extra refresh
35
+ self.enabled = 1
36
+
37
+ async def disable(self) -> None:
38
+ """Disable the Do Not Disturb (DND) timer settings of the device."""
39
+ await self.rpc_channel.send_command(RoborockCommand.CLOSE_DND_TIMER)
40
+ # Optimistic update to avoid an extra refresh
41
+ self.enabled = 0
@@ -0,0 +1,13 @@
1
+ """Trait for dust collection mode."""
2
+
3
+ from roborock_cli._vendor.roborock.data import DustCollectionMode
4
+ from roborock_cli._vendor.roborock.device_features import is_valid_dock
5
+ from roborock_cli._vendor.roborock.devices.traits.v1 import common
6
+ from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
7
+
8
+
9
+ class DustCollectionModeTrait(DustCollectionMode, common.V1TraitMixin):
10
+ """Trait for dust collection mode."""
11
+
12
+ command = RoborockCommand.GET_DUST_COLLECTION_MODE
13
+ requires_dock_type = is_valid_dock
@@ -0,0 +1,29 @@
1
+ from roborock_cli._vendor.roborock.data import FlowLedStatus
2
+ from roborock_cli._vendor.roborock.devices.traits.v1 import common
3
+ from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
4
+
5
+ _STATUS_PARAM = "status"
6
+
7
+
8
+ class FlowLedStatusTrait(FlowLedStatus, common.V1TraitMixin, common.RoborockSwitchBase):
9
+ """Trait for controlling the Flow LED status of a Roborock device."""
10
+
11
+ command = RoborockCommand.GET_FLOW_LED_STATUS
12
+ requires_feature = "is_flow_led_setting_supported"
13
+
14
+ @property
15
+ def is_on(self) -> bool:
16
+ """Return whether the Flow LED status is enabled."""
17
+ return self.status == 1
18
+
19
+ async def enable(self) -> None:
20
+ """Enable the Flow LED status."""
21
+ await self.rpc_channel.send_command(RoborockCommand.SET_FLOW_LED_STATUS, params={_STATUS_PARAM: 1})
22
+ # Optimistic update to avoid an extra refresh
23
+ self.status = 1
24
+
25
+ async def disable(self) -> None:
26
+ """Disable the Flow LED status."""
27
+ await self.rpc_channel.send_command(RoborockCommand.SET_FLOW_LED_STATUS, params={_STATUS_PARAM: 0})
28
+ # Optimistic update to avoid an extra refresh
29
+ self.status = 0
@@ -0,0 +1,285 @@
1
+ """Trait that represents a full view of the home layout.
2
+
3
+ This trait combines information about maps and rooms to provide a comprehensive
4
+ view of the home layout, including room names and their corresponding segment
5
+ on the map. It also makes it straight forward to fetch the map image and data.
6
+
7
+ This trait depends on the MapsTrait and RoomsTrait to gather the necessary
8
+ information. It provides properties to access the current map, the list of
9
+ rooms with names, and the map image and data.
10
+
11
+ Callers may first call `discover_home()` to populate the home layout cache by
12
+ iterating through all available maps on the device. This will cache the map
13
+ information and room names for all maps to minimize map switching and improve
14
+ performance. After the initial discovery, callers can call `refresh()` to update
15
+ the current map's information and room names as needed.
16
+ """
17
+
18
+ import asyncio
19
+ import base64
20
+ import logging
21
+ from typing import Self
22
+
23
+ from roborock_cli._vendor.roborock.data import CombinedMapInfo, MultiMapsListMapInfo, NamedRoomMapping, RoborockBase
24
+ from roborock_cli._vendor.roborock.data.v1.v1_code_mappings import RoborockStateCode
25
+ from roborock_cli._vendor.roborock.devices.cache import DeviceCache
26
+ from roborock_cli._vendor.roborock.devices.traits.v1 import common
27
+ from roborock_cli._vendor.roborock.exceptions import RoborockDeviceBusy, RoborockException, RoborockInvalidStatus
28
+ from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
29
+
30
+ from .map_content import MapContent, MapContentTrait
31
+ from .maps import MapsTrait
32
+ from .rooms import RoomsTrait
33
+ from .status import StatusTrait
34
+
35
+ _LOGGER = logging.getLogger(__name__)
36
+
37
+ MAP_SLEEP = 3
38
+
39
+
40
+ class HomeTrait(RoborockBase, common.V1TraitMixin):
41
+ """Trait that represents a full view of the home layout."""
42
+
43
+ command = RoborockCommand.GET_MAP_V1 # This is not used
44
+
45
+ def __init__(
46
+ self,
47
+ status_trait: StatusTrait,
48
+ maps_trait: MapsTrait,
49
+ map_content: MapContentTrait,
50
+ rooms_trait: RoomsTrait,
51
+ device_cache: DeviceCache,
52
+ ) -> None:
53
+ """Initialize the HomeTrait.
54
+
55
+ We keep track of the MapsTrait and RoomsTrait to provide a comprehensive
56
+ view of the home layout. This also depends on the StatusTrait to determine
57
+ the current map. See comments in MapsTrait for details on that dependency.
58
+
59
+ The cache is used to store discovered home data to minimize map switching
60
+ and improve performance. The cache should be persisted by the caller to
61
+ ensure data is retained across restarts.
62
+
63
+ After initial discovery, only information for the current map is refreshed
64
+ to keep data up to date without excessive map switching. However, as
65
+ users switch rooms, the current map's data will be updated to ensure
66
+ accuracy.
67
+ """
68
+ super().__init__()
69
+ self._status_trait = status_trait
70
+ self._maps_trait = maps_trait
71
+ self._map_content = map_content
72
+ self._rooms_trait = rooms_trait
73
+ self._device_cache = device_cache
74
+ self._discovery_completed = False
75
+ self._home_map_info: dict[int, CombinedMapInfo] | None = None
76
+ self._home_map_content: dict[int, MapContent] | None = None
77
+
78
+ async def discover_home(self) -> None:
79
+ """Iterate through all maps to discover rooms and cache them.
80
+
81
+ This will be a no-op if the home cache is already populated.
82
+
83
+ This cannot be called while the device is cleaning, as that would interrupt the
84
+ cleaning process. This will raise `RoborockDeviceBusy` if the device is
85
+ currently cleaning.
86
+
87
+ After discovery, the home cache will be populated and can be accessed via the `home_map_info` property.
88
+ """
89
+ device_cache_data = await self._device_cache.get()
90
+ if device_cache_data and device_cache_data.home_map_info:
91
+ _LOGGER.debug("Home cache already populated, skipping discovery")
92
+ self._home_map_info = device_cache_data.home_map_info
93
+ self._discovery_completed = True
94
+ try:
95
+ self._home_map_content = {
96
+ k: self._map_content.parse_map_content(base64.b64decode(v))
97
+ for k, v in (device_cache_data.home_map_content_base64 or {}).items()
98
+ }
99
+ except (ValueError, RoborockException) as ex:
100
+ _LOGGER.warning("Failed to parse cached home map content, will re-discover: %s", ex)
101
+ self._home_map_content = {}
102
+ else:
103
+ return
104
+
105
+ if self._status_trait.state == RoborockStateCode.cleaning:
106
+ raise RoborockDeviceBusy("Cannot perform home discovery while the device is cleaning")
107
+
108
+ await self._maps_trait.refresh()
109
+ if self._maps_trait.current_map_info is None:
110
+ raise RoborockException("Cannot perform home discovery without current map info")
111
+
112
+ home_map_info, home_map_content = await self._build_home_map_info()
113
+ _LOGGER.debug("Home discovery complete, caching data for %d maps", len(home_map_info))
114
+ self._discovery_completed = True
115
+ await self._update_home_cache(home_map_info, home_map_content)
116
+
117
+ async def _refresh_map_info(self, map_info: MultiMapsListMapInfo) -> CombinedMapInfo:
118
+ """Collect room data for a specific map and return CombinedMapInfo."""
119
+ await self._rooms_trait.refresh()
120
+
121
+ # We have room names from multiple sources:
122
+ # - The map_info.rooms which we just received from the MultiMapsList
123
+ # - RoomsTrait rooms come from the GET_ROOM_MAPPING command for the current device (only)
124
+ # - RoomsTrait rooms that are pulled from the cloud API
125
+ # We always prefer the RoomsTrait room names since they are always newer and
126
+ # just refreshed above.
127
+ rooms_map: dict[int, NamedRoomMapping] = {
128
+ **map_info.rooms_map,
129
+ **{room.segment_id: room for room in self._rooms_trait.rooms or ()},
130
+ }
131
+ return CombinedMapInfo(
132
+ map_flag=map_info.map_flag,
133
+ name=map_info.name,
134
+ rooms=list(rooms_map.values()),
135
+ )
136
+
137
+ async def _refresh_map_content(self) -> MapContent:
138
+ """Refresh the map content trait to get the latest map data."""
139
+ await self._map_content.refresh()
140
+ return MapContent(
141
+ image_content=self._map_content.image_content,
142
+ map_data=self._map_content.map_data,
143
+ raw_api_response=self._map_content.raw_api_response,
144
+ )
145
+
146
+ async def _build_home_map_info(self) -> tuple[dict[int, CombinedMapInfo], dict[int, MapContent]]:
147
+ """Perform the actual discovery and caching of home map info and content."""
148
+ home_map_info: dict[int, CombinedMapInfo] = {}
149
+ home_map_content: dict[int, MapContent] = {}
150
+
151
+ # Sort map_info to process the current map last, reducing map switching.
152
+ # False (non-original maps) sorts before True (original map). We ensure
153
+ # we load the original map last.
154
+ sorted_map_infos = sorted(
155
+ self._maps_trait.map_info or [],
156
+ key=lambda mi: mi.map_flag == self._maps_trait.current_map,
157
+ reverse=False,
158
+ )
159
+ _LOGGER.debug("Building home cache for maps: %s", [mi.map_flag for mi in sorted_map_infos])
160
+ for map_info in sorted_map_infos:
161
+ # We need to load each map to get its room data
162
+ if len(sorted_map_infos) > 1:
163
+ _LOGGER.debug("Loading map %s", map_info.map_flag)
164
+ try:
165
+ await self._maps_trait.set_current_map(map_info.map_flag)
166
+ except RoborockInvalidStatus as ex:
167
+ # Device is in a state that forbids map switching. Translate to
168
+ # "busy" so callers can fall back to refreshing the current map only.
169
+ raise RoborockDeviceBusy("Cannot switch maps right now (device action locked)") from ex
170
+ await asyncio.sleep(MAP_SLEEP)
171
+
172
+ map_content = await self._refresh_map_content()
173
+ home_map_content[map_info.map_flag] = map_content
174
+
175
+ combined_map_info = await self._refresh_map_info(map_info)
176
+ home_map_info[map_info.map_flag] = combined_map_info
177
+ return home_map_info, home_map_content
178
+
179
+ async def refresh(self) -> None:
180
+ """Refresh current map's underlying map and room data, updating cache as needed.
181
+
182
+ This will only refresh the current map's data and will not populate non
183
+ active maps or re-discover the home. It is expected that this will keep
184
+ information up to date for the current map as users switch to that map.
185
+ """
186
+ if not self._discovery_completed:
187
+ # Running initial discovery also populates all of the same information
188
+ # as below so we can just call that method. If the device is busy
189
+ # then we'll fall through below to refresh the current map only.
190
+ try:
191
+ await self.discover_home()
192
+ return
193
+ except RoborockDeviceBusy:
194
+ _LOGGER.debug("Cannot refresh home data while device is busy cleaning")
195
+
196
+ # Refresh the list of map names/info
197
+ await self._maps_trait.refresh()
198
+ if (current_map_info := self._maps_trait.current_map_info) is None or (
199
+ map_flag := self._maps_trait.current_map
200
+ ) is None:
201
+ raise RoborockException("Cannot refresh home data without current map info")
202
+
203
+ # Refresh the map content to ensure we have the latest image and object positions
204
+ new_map_content = await self._refresh_map_content()
205
+ # Refresh the current map's room data
206
+ combined_map_info = await self._refresh_map_info(current_map_info)
207
+ await self._update_current_map(
208
+ map_flag, combined_map_info, new_map_content, update_cache=self._discovery_completed
209
+ )
210
+
211
+ @property
212
+ def home_map_info(self) -> dict[int, CombinedMapInfo] | None:
213
+ """Returns the map information for all cached maps."""
214
+ return self._home_map_info
215
+
216
+ @property
217
+ def current_map_data(self) -> CombinedMapInfo | None:
218
+ """Returns the map data for the current map."""
219
+ current_map_flag = self._maps_trait.current_map
220
+ if current_map_flag is None or self._home_map_info is None:
221
+ return None
222
+ return self._home_map_info.get(current_map_flag)
223
+
224
+ @property
225
+ def current_rooms(self) -> list[NamedRoomMapping]:
226
+ """Returns the room names for the current map."""
227
+ if self.current_map_data is None:
228
+ return []
229
+ return self.current_map_data.rooms
230
+
231
+ @property
232
+ def home_map_content(self) -> dict[int, MapContent] | None:
233
+ """Returns the map content for all cached maps."""
234
+ return self._home_map_content
235
+
236
+ def _parse_response(self, response: common.V1ResponseData) -> Self:
237
+ """This trait does not parse responses directly."""
238
+ raise NotImplementedError("HomeTrait does not support direct command responses")
239
+
240
+ async def _update_home_cache(
241
+ self, home_map_info: dict[int, CombinedMapInfo], home_map_content: dict[int, MapContent]
242
+ ) -> None:
243
+ """Update the entire home cache with new map info and content."""
244
+ device_cache_data = await self._device_cache.get()
245
+ device_cache_data.home_map_info = home_map_info
246
+ device_cache_data.home_map_content_base64 = {
247
+ k: base64.b64encode(v.raw_api_response).decode("utf-8")
248
+ for k, v in home_map_content.items()
249
+ if v.raw_api_response
250
+ }
251
+ await self._device_cache.set(device_cache_data)
252
+ self._home_map_info = home_map_info
253
+ self._home_map_content = home_map_content
254
+
255
+ async def _update_current_map(
256
+ self,
257
+ map_flag: int,
258
+ map_info: CombinedMapInfo,
259
+ map_content: MapContent,
260
+ update_cache: bool,
261
+ ) -> None:
262
+ """Update the cache for the current map only."""
263
+ # Update the persistent cache if requested e.g. home discovery has
264
+ # completed and we want to keep it fresh. Otherwise just update the
265
+ # in memory map below.
266
+ if update_cache:
267
+ device_cache_data = await self._device_cache.get()
268
+ if device_cache_data.home_map_info is None:
269
+ device_cache_data.home_map_info = {}
270
+ device_cache_data.home_map_info[map_flag] = map_info
271
+ if map_content.raw_api_response:
272
+ if device_cache_data.home_map_content_base64 is None:
273
+ device_cache_data.home_map_content_base64 = {}
274
+ device_cache_data.home_map_content_base64[map_flag] = base64.b64encode(
275
+ map_content.raw_api_response
276
+ ).decode("utf-8")
277
+ await self._device_cache.set(device_cache_data)
278
+
279
+ if self._home_map_info is None:
280
+ self._home_map_info = {}
281
+ self._home_map_info[map_flag] = map_info
282
+
283
+ if self._home_map_content is None:
284
+ self._home_map_content = {}
285
+ self._home_map_content[map_flag] = map_content
@@ -0,0 +1,43 @@
1
+ from roborock_cli._vendor.roborock.data import LedStatus
2
+ from roborock_cli._vendor.roborock.devices.traits.v1 import common
3
+ from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
4
+
5
+ from .common import V1ResponseData
6
+
7
+
8
+ class LedStatusTrait(LedStatus, common.V1TraitMixin, common.RoborockSwitchBase):
9
+ """Trait for controlling the LED status of a Roborock device."""
10
+
11
+ command = RoborockCommand.GET_LED_STATUS
12
+ requires_feature = "is_led_status_switch_supported"
13
+
14
+ @property
15
+ def is_on(self) -> bool:
16
+ """Return whether the LED status is enabled."""
17
+ return self.status == 1
18
+
19
+ async def enable(self) -> None:
20
+ """Enable the LED status."""
21
+ await self.rpc_channel.send_command(RoborockCommand.SET_LED_STATUS, params=[1])
22
+ # Optimistic update to avoid an extra refresh
23
+ self.status = 1
24
+
25
+ async def disable(self) -> None:
26
+ """Disable the LED status."""
27
+ await self.rpc_channel.send_command(RoborockCommand.SET_LED_STATUS, params=[0])
28
+ # Optimistic update to avoid an extra refresh
29
+ self.status = 0
30
+
31
+ @classmethod
32
+ def _parse_type_response(cls, response: V1ResponseData) -> LedStatus:
33
+ """Parse the response from the device into a a RoborockBase.
34
+
35
+ Subclasses should override this method to implement custom parsing
36
+ logic as needed.
37
+ """
38
+ if not isinstance(response, list):
39
+ raise ValueError(f"Unexpected {cls} response format: {response!r}")
40
+ response = response[0]
41
+ if not isinstance(response, int):
42
+ raise ValueError(f"Unexpected {cls} response format: {response!r}")
43
+ return cls.from_dict({"status": response})
@@ -0,0 +1,83 @@
1
+ """Trait for fetching the map content from Roborock devices."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+
6
+ from vacuum_map_parser_base.map_data import MapData
7
+
8
+ from roborock_cli._vendor.roborock.data import RoborockBase
9
+ from roborock_cli._vendor.roborock.devices.traits.v1 import common
10
+ from roborock_cli._vendor.roborock.map.map_parser import MapParser, MapParserConfig
11
+ from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
12
+
13
+ _LOGGER = logging.getLogger(__name__)
14
+
15
+ _TRUNCATE_LENGTH = 20
16
+
17
+
18
+ @dataclass
19
+ class MapContent(RoborockBase):
20
+ """Dataclass representing map content."""
21
+
22
+ image_content: bytes | None = None
23
+ """The rendered image of the map in PNG format."""
24
+
25
+ map_data: MapData | None = None
26
+ """The parsed map data which contains metadata for points on the map."""
27
+
28
+ raw_api_response: bytes | None = None
29
+ """The raw bytes of the map data from the API for caching for future use.
30
+
31
+ This should be treated as an opaque blob used only internally by this library
32
+ to re-parse the map data when needed.
33
+ """
34
+
35
+ def __repr__(self) -> str:
36
+ """Return a string representation of the MapContent."""
37
+ img = self.image_content
38
+ if self.image_content and len(self.image_content) > _TRUNCATE_LENGTH:
39
+ img = self.image_content[: _TRUNCATE_LENGTH - 3] + b"..."
40
+ return f"MapContent(image_content={img!r}, map_data={self.map_data!r})"
41
+
42
+
43
+ @common.map_rpc_channel
44
+ class MapContentTrait(MapContent, common.V1TraitMixin):
45
+ """Trait for fetching the map content."""
46
+
47
+ command = RoborockCommand.GET_MAP_V1
48
+
49
+ def __init__(self, map_parser_config: MapParserConfig | None = None) -> None:
50
+ """Initialize MapContentTrait."""
51
+ super().__init__()
52
+ self._map_parser = MapParser(map_parser_config or MapParserConfig())
53
+
54
+ def _parse_response(self, response: common.V1ResponseData) -> MapContent:
55
+ """Parse the response from the device into a MapContentTrait instance."""
56
+ if not isinstance(response, bytes):
57
+ raise ValueError(f"Unexpected MapContentTrait response format: {type(response)}")
58
+ return self.parse_map_content(response)
59
+
60
+ def parse_map_content(self, response: bytes) -> MapContent:
61
+ """Parse the map content from raw bytes.
62
+
63
+ This method is exposed so that cached map data can be parsed without
64
+ needing to go through the RPC channel.
65
+
66
+ Args:
67
+ response: The raw bytes of the map data from the API.
68
+
69
+ Returns:
70
+ MapContent: The parsed map content.
71
+
72
+ Raises:
73
+ RoborockException: If the map data cannot be parsed.
74
+ """
75
+ parsed_data = self._map_parser.parse(response)
76
+ if parsed_data is None:
77
+ raise ValueError("Failed to parse map data")
78
+
79
+ return MapContent(
80
+ image_content=parsed_data.image_content,
81
+ map_data=parsed_data.map_data,
82
+ raw_api_response=response,
83
+ )