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,344 @@
1
+ """Create traits for V1 devices.
2
+
3
+ Traits are modular components that encapsulate specific features of a Roborock
4
+ device. This module provides a factory function to create and initialize the
5
+ appropriate traits for V1 devices based on their capabilities.
6
+
7
+ Using Traits
8
+ ------------
9
+ Traits are accessed via the `v1_properties` attribute on a device. Each trait
10
+ represents a specific capability, such as `status`, `consumables`, or `rooms`.
11
+
12
+ Traits serve two main purposes:
13
+ 1. **State**: Traits are dataclasses that hold the current state of the device
14
+ feature. You can access attributes directly (e.g., `device.v1_properties.status.battery`).
15
+ 2. **Commands**: Traits provide methods to control the device. For example,
16
+ `device.v1_properties.volume.set_volume()`.
17
+
18
+ Additionally, the `command` trait provides a generic way to send any command to the
19
+ device (e.g. `device.v1_properties.command.send("app_start")`). This is often used
20
+ for basic cleaning operations like starting, stopping, or docking the vacuum.
21
+
22
+ Most traits have a `refresh()` method that must be called to update their state
23
+ from the device. The state is not updated automatically in real-time unless
24
+ specifically implemented by the trait or via polling.
25
+
26
+ Adding New Traits
27
+ -----------------
28
+ When adding a new trait, the most common pattern is to subclass `V1TraitMixin`
29
+ and a `RoborockBase` dataclass. You must define a `command` class variable that
30
+ specifies the `RoborockCommand` used to fetch the trait data from the device.
31
+ See `common.py` for more details on common patterns used across traits.
32
+
33
+ There are some additional decorators in `common.py` that can be used to specify which
34
+ RPC channel to use for the trait (standard, MQTT/cloud, or map-specific).
35
+
36
+ - `@common.mqtt_rpc_channel` - Use the MQTT RPC channel for this trait.
37
+ - `@common.map_rpc_channel` - Use the map RPC channel for this trait.
38
+
39
+ There are also some attributes that specify device feature dependencies for
40
+ optional traits:
41
+
42
+ - `requires_feature` - The string name of the device feature that must be supported
43
+ for this trait to be enabled. See `DeviceFeaturesTrait` for a list of
44
+ available features.
45
+ - `requires_dock_type` - If set, this is a function that accepts a `RoborockDockTypeCode`
46
+ and returns a boolean indicating whether the trait is supported for that dock type.
47
+
48
+ Additionally, DeviceFeaturesTrait has a method `is_field_supported` that is used to
49
+ check individual trait field values. This is a more fine grained version to allow
50
+ optional fields in a dataclass, vs the above feature checks that apply to an entire
51
+ trait. The `requires_schema_code` field metadata attribute is a string of the schema
52
+ code in HomeDataProduct Schema that is required for the field to be supported.
53
+ """
54
+
55
+ import logging
56
+ from dataclasses import dataclass, field, fields
57
+ from typing import Any, get_args
58
+
59
+ from roborock_cli._vendor.roborock.data.containers import HomeData, HomeDataProduct, RoborockBase
60
+ from roborock_cli._vendor.roborock.data.v1.v1_code_mappings import RoborockDockTypeCode
61
+ from roborock_cli._vendor.roborock.devices.cache import DeviceCache
62
+ from roborock_cli._vendor.roborock.devices.traits import Trait
63
+ from roborock_cli._vendor.roborock.map.map_parser import MapParserConfig
64
+ from roborock_cli._vendor.roborock.protocols.v1_protocol import V1RpcChannel
65
+ from roborock_cli._vendor.roborock.web_api import UserWebApiClient
66
+
67
+ from . import (
68
+ child_lock,
69
+ clean_summary,
70
+ command,
71
+ common,
72
+ consumeable,
73
+ device_features,
74
+ do_not_disturb,
75
+ dust_collection_mode,
76
+ flow_led_status,
77
+ home,
78
+ led_status,
79
+ map_content,
80
+ maps,
81
+ network_info,
82
+ rooms,
83
+ routines,
84
+ smart_wash_params,
85
+ status,
86
+ valley_electricity_timer,
87
+ volume,
88
+ wash_towel_mode,
89
+ )
90
+ from .child_lock import ChildLockTrait
91
+ from .clean_summary import CleanSummaryTrait
92
+ from .command import CommandTrait
93
+ from .common import V1TraitMixin
94
+ from .consumeable import ConsumableTrait
95
+ from .device_features import DeviceFeaturesTrait
96
+ from .do_not_disturb import DoNotDisturbTrait
97
+ from .dust_collection_mode import DustCollectionModeTrait
98
+ from .flow_led_status import FlowLedStatusTrait
99
+ from .home import HomeTrait
100
+ from .led_status import LedStatusTrait
101
+ from .map_content import MapContentTrait
102
+ from .maps import MapsTrait
103
+ from .network_info import NetworkInfoTrait
104
+ from .rooms import RoomsTrait
105
+ from .routines import RoutinesTrait
106
+ from .smart_wash_params import SmartWashParamsTrait
107
+ from .status import StatusTrait
108
+ from .valley_electricity_timer import ValleyElectricityTimerTrait
109
+ from .volume import SoundVolumeTrait
110
+ from .wash_towel_mode import WashTowelModeTrait
111
+
112
+ _LOGGER = logging.getLogger(__name__)
113
+
114
+ __all__ = [
115
+ "PropertiesApi",
116
+ "child_lock",
117
+ "clean_summary",
118
+ "command",
119
+ "common",
120
+ "consumeable",
121
+ "device_features",
122
+ "do_not_disturb",
123
+ "dust_collection_mode",
124
+ "flow_led_status",
125
+ "home",
126
+ "led_status",
127
+ "map_content",
128
+ "maps",
129
+ "network_info",
130
+ "rooms",
131
+ "routines",
132
+ "smart_wash_params",
133
+ "status",
134
+ "valley_electricity_timer",
135
+ "volume",
136
+ "wash_towel_mode",
137
+ ]
138
+
139
+
140
+ @dataclass
141
+ class PropertiesApi(Trait):
142
+ """Common properties for V1 devices.
143
+
144
+ This class holds all the traits that are common across all V1 devices.
145
+ """
146
+
147
+ # All v1 devices have these traits
148
+ status: StatusTrait
149
+ command: CommandTrait
150
+ dnd: DoNotDisturbTrait
151
+ clean_summary: CleanSummaryTrait
152
+ sound_volume: SoundVolumeTrait
153
+ rooms: RoomsTrait
154
+ maps: MapsTrait
155
+ map_content: MapContentTrait
156
+ consumables: ConsumableTrait
157
+ home: HomeTrait
158
+ device_features: DeviceFeaturesTrait
159
+ network_info: NetworkInfoTrait
160
+ routines: RoutinesTrait
161
+
162
+ # Optional features that may not be supported on all devices
163
+ child_lock: ChildLockTrait | None = None
164
+ led_status: LedStatusTrait | None = None
165
+ flow_led_status: FlowLedStatusTrait | None = None
166
+ valley_electricity_timer: ValleyElectricityTimerTrait | None = None
167
+ dust_collection_mode: DustCollectionModeTrait | None = None
168
+ wash_towel_mode: WashTowelModeTrait | None = None
169
+ smart_wash_params: SmartWashParamsTrait | None = None
170
+
171
+ def __init__(
172
+ self,
173
+ device_uid: str,
174
+ product: HomeDataProduct,
175
+ home_data: HomeData,
176
+ rpc_channel: V1RpcChannel,
177
+ mqtt_rpc_channel: V1RpcChannel,
178
+ map_rpc_channel: V1RpcChannel,
179
+ web_api: UserWebApiClient,
180
+ device_cache: DeviceCache,
181
+ map_parser_config: MapParserConfig | None = None,
182
+ region: str | None = None,
183
+ ) -> None:
184
+ """Initialize the V1TraitProps."""
185
+ self._device_uid = device_uid
186
+ self._rpc_channel = rpc_channel
187
+ self._mqtt_rpc_channel = mqtt_rpc_channel
188
+ self._map_rpc_channel = map_rpc_channel
189
+ self._web_api = web_api
190
+ self._device_cache = device_cache
191
+ self._region = region
192
+
193
+ self.device_features = DeviceFeaturesTrait(product, self._device_cache)
194
+ self.status = StatusTrait(self.device_features, region=self._region)
195
+ self.consumables = ConsumableTrait()
196
+ self.rooms = RoomsTrait(home_data, web_api)
197
+ self.maps = MapsTrait(self.status)
198
+ self.map_content = MapContentTrait(map_parser_config)
199
+ self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache)
200
+ self.network_info = NetworkInfoTrait(device_uid, self._device_cache)
201
+ self.routines = RoutinesTrait(device_uid, web_api)
202
+
203
+ # Dynamically create any traits that need to be populated
204
+ for item in fields(self):
205
+ if (trait := getattr(self, item.name, None)) is None:
206
+ # We exclude optional features and them via discover_features
207
+ if (union_args := get_args(item.type)) is None or len(union_args) > 0:
208
+ continue
209
+ _LOGGER.debug("Trait '%s' is supported, initializing", item.name)
210
+ if not callable(item.type):
211
+ continue
212
+ trait = item.type()
213
+ setattr(self, item.name, trait)
214
+ # This is a hack to allow setting the rpc_channel on all traits. This is
215
+ # used so we can preserve the dataclass behavior when the values in the
216
+ # traits are updated, but still want to allow them to have a reference
217
+ # to the rpc channel for sending commands.
218
+ trait._rpc_channel = self._get_rpc_channel(trait)
219
+
220
+ def _get_rpc_channel(self, trait: V1TraitMixin) -> V1RpcChannel:
221
+ # The decorator `@common.mqtt_rpc_channel` means that the trait needs
222
+ # to use the mqtt_rpc_channel (cloud only) instead of the rpc_channel (adaptive)
223
+ if hasattr(trait, "mqtt_rpc_channel"):
224
+ return self._mqtt_rpc_channel
225
+ elif hasattr(trait, "map_rpc_channel"):
226
+ return self._map_rpc_channel
227
+ else:
228
+ return self._rpc_channel
229
+
230
+ async def discover_features(self) -> None:
231
+ """Populate any supported traits that were not initialized in __init__."""
232
+ _LOGGER.debug("Starting optional trait discovery")
233
+ await self.device_features.refresh()
234
+ # Dock type also acts like a device feature for some traits.
235
+ dock_type = await self._dock_type()
236
+
237
+ # Dynamically create any traits that need to be populated
238
+ for item in fields(self):
239
+ if (trait := getattr(self, item.name, None)) is not None:
240
+ continue
241
+ if (union_args := get_args(item.type)) is None:
242
+ raise ValueError(f"Unexpected non-union type for trait {item.name}: {item.type}")
243
+ if len(union_args) != 2 or type(None) not in union_args:
244
+ raise ValueError(f"Unexpected non-optional type for trait {item.name}: {item.type}")
245
+
246
+ # Union args may not be in declared order
247
+ item_type = union_args[0] if union_args[1] is type(None) else union_args[1]
248
+ if not self._is_supported(item_type, item.name, dock_type):
249
+ _LOGGER.debug("Trait '%s' not supported, skipping", item.name)
250
+ continue
251
+ _LOGGER.debug("Trait '%s' is supported, initializing", item.name)
252
+ trait = item_type()
253
+ setattr(self, item.name, trait)
254
+ trait._rpc_channel = self._get_rpc_channel(trait)
255
+
256
+ def _is_supported(self, trait_type: type[V1TraitMixin], name: str, dock_type: RoborockDockTypeCode) -> bool:
257
+ """Check if a trait is supported by the device."""
258
+
259
+ if (requires_dock_type := getattr(trait_type, "requires_dock_type", None)) is not None:
260
+ return requires_dock_type(dock_type)
261
+
262
+ if (feature_name := getattr(trait_type, "requires_feature", None)) is None:
263
+ _LOGGER.debug("Optional trait missing 'requires_feature' attribute %s, skipping", name)
264
+ return False
265
+ if (is_supported := getattr(self.device_features, feature_name)) is None:
266
+ raise ValueError(f"Device feature '{feature_name}' on trait '{name}' is unknown")
267
+ return is_supported
268
+
269
+ async def _dock_type(self) -> RoborockDockTypeCode:
270
+ """Get the dock type from the status trait or cache."""
271
+ dock_type = await self._get_cached_trait_data("dock_type")
272
+ if dock_type is not None:
273
+ _LOGGER.debug("Using cached dock type: %s", dock_type)
274
+ try:
275
+ return RoborockDockTypeCode(dock_type)
276
+ except ValueError:
277
+ _LOGGER.debug("Cached dock type %s is invalid, refreshing", dock_type)
278
+
279
+ _LOGGER.debug("Starting dock type discovery")
280
+ await self.status.refresh()
281
+ _LOGGER.debug("Fetched dock type: %s", self.status.dock_type)
282
+ if self.status.dock_type is None:
283
+ # Explicitly set so we reuse cached value next type
284
+ dock_type = RoborockDockTypeCode.no_dock
285
+ else:
286
+ dock_type = self.status.dock_type
287
+ await self._set_cached_trait_data("dock_type", dock_type)
288
+ return dock_type
289
+
290
+ async def _get_cached_trait_data(self, name: str) -> Any:
291
+ """Get the dock type from the status trait or cache."""
292
+ cache_data = await self._device_cache.get()
293
+ if cache_data.trait_data is None:
294
+ cache_data.trait_data = {}
295
+ _LOGGER.debug("Cached trait data: %s", cache_data.trait_data)
296
+ return cache_data.trait_data.get(name)
297
+
298
+ async def _set_cached_trait_data(self, name: str, value: Any) -> None:
299
+ """Set trait-specific cached data."""
300
+ cache_data = await self._device_cache.get()
301
+ if cache_data.trait_data is None:
302
+ cache_data.trait_data = {}
303
+ cache_data.trait_data[name] = value
304
+ _LOGGER.debug("Updating cached trait data: %s", cache_data.trait_data)
305
+ await self._device_cache.set(cache_data)
306
+
307
+ def as_dict(self) -> dict[str, Any]:
308
+ """Return the trait data as a dictionary."""
309
+ result: dict[str, Any] = {}
310
+ for item in fields(self):
311
+ trait = getattr(self, item.name, None)
312
+ if trait is None or not isinstance(trait, RoborockBase):
313
+ continue
314
+ data = trait.as_dict()
315
+ if data: # Don't omit unset traits
316
+ result[item.name] = data
317
+ return result
318
+
319
+
320
+ def create(
321
+ device_uid: str,
322
+ product: HomeDataProduct,
323
+ home_data: HomeData,
324
+ rpc_channel: V1RpcChannel,
325
+ mqtt_rpc_channel: V1RpcChannel,
326
+ map_rpc_channel: V1RpcChannel,
327
+ web_api: UserWebApiClient,
328
+ device_cache: DeviceCache,
329
+ map_parser_config: MapParserConfig | None = None,
330
+ region: str | None = None,
331
+ ) -> PropertiesApi:
332
+ """Create traits for V1 devices."""
333
+ return PropertiesApi(
334
+ device_uid,
335
+ product,
336
+ home_data,
337
+ rpc_channel,
338
+ mqtt_rpc_channel,
339
+ map_rpc_channel,
340
+ web_api,
341
+ device_cache,
342
+ map_parser_config,
343
+ region=region,
344
+ )
@@ -0,0 +1,29 @@
1
+ from roborock_cli._vendor.roborock.data import ChildLockStatus
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 = "lock_status"
6
+
7
+
8
+ class ChildLockTrait(ChildLockStatus, common.V1TraitMixin, common.RoborockSwitchBase):
9
+ """Trait for controlling the child lock of a Roborock device."""
10
+
11
+ command = RoborockCommand.GET_CHILD_LOCK_STATUS
12
+ requires_feature = "is_set_child_supported"
13
+
14
+ @property
15
+ def is_on(self) -> bool:
16
+ """Return whether the child lock is enabled."""
17
+ return self.lock_status == 1
18
+
19
+ async def enable(self) -> None:
20
+ """Enable the child lock."""
21
+ await self.rpc_channel.send_command(RoborockCommand.SET_CHILD_LOCK_STATUS, params={_STATUS_PARAM: 1})
22
+ # Optimistic update to avoid an extra refresh
23
+ self.lock_status = 1
24
+
25
+ async def disable(self) -> None:
26
+ """Disable the child lock."""
27
+ await self.rpc_channel.send_command(RoborockCommand.SET_CHILD_LOCK_STATUS, params={_STATUS_PARAM: 0})
28
+ # Optimistic update to avoid an extra refresh
29
+ self.lock_status = 0
@@ -0,0 +1,83 @@
1
+ import logging
2
+ from typing import Self
3
+
4
+ from roborock_cli._vendor.roborock.data import CleanRecord, CleanSummaryWithDetail
5
+ from roborock_cli._vendor.roborock.devices.traits.v1 import common
6
+ from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
7
+ from roborock_cli._vendor.roborock.util import unpack_list
8
+
9
+ _LOGGER = logging.getLogger(__name__)
10
+
11
+
12
+ class CleanSummaryTrait(CleanSummaryWithDetail, common.V1TraitMixin):
13
+ """Trait for managing the clean summary of Roborock devices."""
14
+
15
+ command = RoborockCommand.GET_CLEAN_SUMMARY
16
+
17
+ async def refresh(self) -> None:
18
+ """Refresh the clean summary data and last clean record.
19
+
20
+ Assumes that the clean summary has already been fetched.
21
+ """
22
+ await super().refresh()
23
+ if not self.records:
24
+ _LOGGER.debug("No clean records available in clean summary.")
25
+ self.last_clean_record = None
26
+ return
27
+ last_record_id = self.records[0]
28
+ self.last_clean_record = await self.get_clean_record(last_record_id)
29
+
30
+ @classmethod
31
+ def _parse_type_response(cls, response: common.V1ResponseData) -> Self:
32
+ """Parse the response from the device into a CleanSummary."""
33
+ if isinstance(response, dict):
34
+ return cls.from_dict(response)
35
+ elif isinstance(response, list):
36
+ clean_time, clean_area, clean_count, records = unpack_list(response, 4)
37
+ return cls(
38
+ clean_time=clean_time,
39
+ clean_area=clean_area,
40
+ clean_count=clean_count,
41
+ records=records,
42
+ )
43
+ elif isinstance(response, int):
44
+ return cls(clean_time=response)
45
+ raise ValueError(f"Unexpected clean summary format: {response!r}")
46
+
47
+ async def get_clean_record(self, record_id: int) -> CleanRecord:
48
+ """Load a specific clean record by ID."""
49
+ response = await self.rpc_channel.send_command(RoborockCommand.GET_CLEAN_RECORD, params=[record_id])
50
+ return self._parse_clean_record_response(response)
51
+
52
+ @classmethod
53
+ def _parse_clean_record_response(cls, response: common.V1ResponseData) -> CleanRecord:
54
+ """Parse the response from the device into a CleanRecord."""
55
+ if isinstance(response, list) and len(response) == 1:
56
+ response = response[0]
57
+ if isinstance(response, dict):
58
+ return CleanRecord.from_dict(response)
59
+ if isinstance(response, list):
60
+ if isinstance(response[-1], dict):
61
+ records = [CleanRecord.from_dict(rec) for rec in response]
62
+ final_record = records[-1]
63
+ try:
64
+ # This code is semi-presumptuous - so it is put in a try finally to be safe.
65
+ final_record.begin = records[0].begin
66
+ final_record.begin_datetime = records[0].begin_datetime
67
+ final_record.start_type = records[0].start_type
68
+ for rec in records[0:-1]:
69
+ final_record.duration = (final_record.duration or 0) + (rec.duration or 0)
70
+ final_record.area = (final_record.area or 0) + (rec.area or 0)
71
+ final_record.avoid_count = (final_record.avoid_count or 0) + (rec.avoid_count or 0)
72
+ final_record.wash_count = (final_record.wash_count or 0) + (rec.wash_count or 0)
73
+ final_record.square_meter_area = (final_record.square_meter_area or 0) + (
74
+ rec.square_meter_area or 0
75
+ )
76
+ return final_record
77
+ except Exception:
78
+ # Return final record when an exception occurred
79
+ return final_record
80
+ # There are still a few unknown variables in this.
81
+ begin, end, duration, area = unpack_list(response, 4)
82
+ return CleanRecord(begin=begin, end=end, duration=duration, area=area)
83
+ raise ValueError(f"Unexpected clean record format: {response!r}")
@@ -0,0 +1,38 @@
1
+ from typing import Any
2
+
3
+ from roborock_cli._vendor.roborock import RoborockCommand
4
+ from roborock_cli._vendor.roborock.protocols.v1_protocol import ParamsType
5
+
6
+
7
+ class CommandTrait:
8
+ """Trait for sending commands to Roborock devices.
9
+
10
+ This trait allows sending raw commands directly to the device. It is particularly
11
+ useful for:
12
+ 1. **Cleaning Control**: Sending commands like `app_start`, `app_stop`, `app_pause`,
13
+ or `app_charge` which don't belong to a specific state trait.
14
+ 2. **Unsupported Features**: Accessing device functionality that hasn't been
15
+ mapped to a specific trait yet.
16
+
17
+ See `roborock.roborock_typing.RoborockCommand` for a list of available commands.
18
+ """
19
+
20
+ def __post_init__(self) -> None:
21
+ """Post-initialization to set up the RPC channel.
22
+
23
+ This is called automatically after the dataclass is initialized by the
24
+ device setup code.
25
+ """
26
+ self._rpc_channel = None
27
+
28
+ async def send(self, command: RoborockCommand | str, params: ParamsType = None) -> Any:
29
+ """Send a command to the device.
30
+
31
+ Sending a raw command to the device using this method does not update
32
+ the internal state of any other traits. It is the responsibility of the
33
+ caller to ensure that any traits affected by the command are refreshed
34
+ as needed.
35
+ """
36
+ if not self._rpc_channel:
37
+ raise ValueError("Device trait in invalid state")
38
+ return await self._rpc_channel.send_command(command, params=params)
@@ -0,0 +1,172 @@
1
+ """Module for Roborock V1 devices common trait commands.
2
+
3
+ This is an internal library and should not be used directly by consumers.
4
+ """
5
+
6
+ import logging
7
+ from abc import ABC, abstractmethod
8
+ from dataclasses import dataclass, fields
9
+ from typing import ClassVar, Self
10
+
11
+ from roborock_cli._vendor.roborock.data import RoborockBase
12
+ from roborock_cli._vendor.roborock.protocols.v1_protocol import V1RpcChannel
13
+ from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+ V1ResponseData = dict | list | int | str
18
+
19
+
20
+ @dataclass
21
+ class V1TraitMixin(ABC):
22
+ """Base model that supports v1 traits.
23
+
24
+ This class provides functioanlity for parsing responses from V1 devices
25
+ into dataclass instances. It also provides a reference to the V1RpcChannel
26
+ used to communicate with the device to execute commands.
27
+
28
+ Each trait subclass must define a class variable `command` that specifies
29
+ the RoborockCommand used to fetch the trait data from the device. The
30
+ `refresh()` method can be called to update the contents of the trait data
31
+ from the device.
32
+
33
+ A trait can also support additional commands for updating state associated
34
+ with the trait. It is expected that a trait will update its own internal
35
+ state either reflecting the change optimistically or by refreshing the
36
+ trait state from the device. In cases where one trait caches data that is
37
+ also represented in another trait, it is the responsibility of the caller
38
+ to ensure that both traits are refreshed as needed to keep them in sync.
39
+
40
+ The traits typically subclass RoborockBase to provide serialization
41
+ and deserialization functionality, but this is not strictly required.
42
+ """
43
+
44
+ command: ClassVar[RoborockCommand]
45
+
46
+ @classmethod
47
+ def _parse_type_response(cls, response: V1ResponseData) -> RoborockBase:
48
+ """Parse the response from the device into a a RoborockBase.
49
+
50
+ Subclasses should override this method to implement custom parsing
51
+ logic as needed.
52
+ """
53
+ if not issubclass(cls, RoborockBase):
54
+ raise NotImplementedError(f"Trait {cls} does not implement RoborockBase")
55
+ # Subclasses can override to implement custom parsing logic
56
+ if isinstance(response, list):
57
+ response = response[0]
58
+ if not isinstance(response, dict):
59
+ raise ValueError(f"Unexpected {cls} response format: {response!r}")
60
+ return cls.from_dict(response)
61
+
62
+ def _parse_response(self, response: V1ResponseData) -> RoborockBase:
63
+ """Parse the response from the device into a a RoborockBase.
64
+
65
+ This is used by subclasses that want to override the class
66
+ behavior with instance-specific data.
67
+ """
68
+ return self._parse_type_response(response)
69
+
70
+ def __post_init__(self) -> None:
71
+ """Post-initialization to set up the RPC channel.
72
+
73
+ This is called automatically after the dataclass is initialized by the
74
+ device setup code.
75
+ """
76
+ self._rpc_channel = None
77
+
78
+ @property
79
+ def rpc_channel(self) -> V1RpcChannel:
80
+ """Helper for executing commands, used internally by the trait"""
81
+ if not self._rpc_channel:
82
+ raise ValueError("Device trait in invalid state")
83
+ return self._rpc_channel
84
+
85
+ async def refresh(self) -> None:
86
+ """Refresh the contents of this trait."""
87
+ response = await self.rpc_channel.send_command(self.command)
88
+ new_data = self._parse_response(response)
89
+ if not isinstance(new_data, RoborockBase):
90
+ raise ValueError(f"Internal error, unexpected response type: {new_data!r}")
91
+ _LOGGER.debug("Refreshed %s: %s", self.__class__.__name__, new_data)
92
+ self._update_trait_values(new_data)
93
+
94
+ def _update_trait_values(self, new_data: RoborockBase) -> None:
95
+ """Update the values of this trait from another instance."""
96
+ for field in fields(new_data):
97
+ new_value = getattr(new_data, field.name, None)
98
+ setattr(self, field.name, new_value)
99
+
100
+
101
+ def _get_value_field(clazz: type[V1TraitMixin]) -> str:
102
+ """Get the name of the field marked as the main value of the RoborockValueBase."""
103
+ value_fields = [field.name for field in fields(clazz) if field.metadata.get("roborock_value", False)]
104
+ if len(value_fields) != 1:
105
+ raise ValueError(
106
+ f"RoborockValueBase subclass {clazz} must have exactly one field marked as roborock_value, "
107
+ f" but found: {value_fields}"
108
+ )
109
+ return value_fields[0]
110
+
111
+
112
+ @dataclass(init=False, kw_only=True)
113
+ class RoborockValueBase(V1TraitMixin, RoborockBase):
114
+ """Base class for traits that represent a single value.
115
+
116
+ This class is intended to be subclassed by traits that represent a single
117
+ value, such as volume or brightness. The subclass should define a single
118
+ field with the metadata `roborock_value=True` to indicate which field
119
+ represents the main value of the trait.
120
+ """
121
+
122
+ @classmethod
123
+ def _parse_response(cls, response: V1ResponseData) -> Self:
124
+ """Parse the response from the device into a RoborockValueBase."""
125
+ if isinstance(response, list):
126
+ response = response[0]
127
+ if not isinstance(response, int):
128
+ raise ValueError(f"Unexpected response format: {response!r}")
129
+ value_field = _get_value_field(cls)
130
+ return cls(**{value_field: response})
131
+
132
+
133
+ class RoborockSwitchBase(ABC):
134
+ """Base class for traits that represent a boolean switch."""
135
+
136
+ @property
137
+ @abstractmethod
138
+ def is_on(self) -> bool:
139
+ """Return whether the switch is on."""
140
+
141
+ @abstractmethod
142
+ async def enable(self) -> None:
143
+ """Enable the switch."""
144
+
145
+ @abstractmethod
146
+ async def disable(self) -> None:
147
+ """Disable the switch."""
148
+
149
+
150
+ def mqtt_rpc_channel(cls):
151
+ """Decorator to mark a function as cloud only.
152
+
153
+ Normally a trait uses an adaptive rpc channel that can use either local
154
+ or cloud communication depending on what is available. This will force
155
+ the trait to always use the cloud rpc channel.
156
+ """
157
+
158
+ def wrapper(*args, **kwargs):
159
+ return cls(*args, **kwargs)
160
+
161
+ cls.mqtt_rpc_channel = True # type: ignore[attr-defined]
162
+ return wrapper
163
+
164
+
165
+ def map_rpc_channel(cls):
166
+ """Decorator to mark a function as cloud only using the map rpc format."""
167
+
168
+ def wrapper(*args, **kwargs):
169
+ return cls(*args, **kwargs)
170
+
171
+ cls.map_rpc_channel = True # type: ignore[attr-defined]
172
+ return wrapper