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.
- roborock_cli/__init__.py +3 -0
- roborock_cli/__main__.py +76 -0
- roborock_cli/_vendor/VERSION +6 -0
- roborock_cli/_vendor/__init__.py +0 -0
- roborock_cli/_vendor/roborock/__init__.py +27 -0
- roborock_cli/_vendor/roborock/broadcast_protocol.py +114 -0
- roborock_cli/_vendor/roborock/callbacks.py +130 -0
- roborock_cli/_vendor/roborock/cli.py +1338 -0
- roborock_cli/_vendor/roborock/const.py +84 -0
- roborock_cli/_vendor/roborock/data/__init__.py +9 -0
- roborock_cli/_vendor/roborock/data/b01_q10/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_code_mappings.py +213 -0
- roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_containers.py +102 -0
- roborock_cli/_vendor/roborock/data/b01_q7/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_code_mappings.py +303 -0
- roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_containers.py +302 -0
- roborock_cli/_vendor/roborock/data/code_mappings.py +198 -0
- roborock_cli/_vendor/roborock/data/containers.py +530 -0
- roborock_cli/_vendor/roborock/data/dyad/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/dyad/dyad_code_mappings.py +102 -0
- roborock_cli/_vendor/roborock/data/dyad/dyad_containers.py +28 -0
- roborock_cli/_vendor/roborock/data/v1/__init__.py +3 -0
- roborock_cli/_vendor/roborock/data/v1/v1_clean_modes.py +192 -0
- roborock_cli/_vendor/roborock/data/v1/v1_code_mappings.py +644 -0
- roborock_cli/_vendor/roborock/data/v1/v1_containers.py +800 -0
- roborock_cli/_vendor/roborock/data/zeo/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/zeo/zeo_code_mappings.py +138 -0
- roborock_cli/_vendor/roborock/data/zeo/zeo_containers.py +0 -0
- roborock_cli/_vendor/roborock/device_features.py +668 -0
- roborock_cli/_vendor/roborock/devices/README.md +41 -0
- roborock_cli/_vendor/roborock/devices/__init__.py +11 -0
- roborock_cli/_vendor/roborock/devices/cache.py +143 -0
- roborock_cli/_vendor/roborock/devices/device.py +240 -0
- roborock_cli/_vendor/roborock/devices/device_manager.py +269 -0
- roborock_cli/_vendor/roborock/devices/file_cache.py +79 -0
- roborock_cli/_vendor/roborock/devices/rpc/__init__.py +14 -0
- roborock_cli/_vendor/roborock/devices/rpc/a01_channel.py +94 -0
- roborock_cli/_vendor/roborock/devices/rpc/b01_q10_channel.py +57 -0
- roborock_cli/_vendor/roborock/devices/rpc/b01_q7_channel.py +101 -0
- roborock_cli/_vendor/roborock/devices/rpc/v1_channel.py +457 -0
- roborock_cli/_vendor/roborock/devices/traits/__init__.py +28 -0
- roborock_cli/_vendor/roborock/devices/traits/a01/__init__.py +191 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/__init__.py +12 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/__init__.py +76 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/command.py +32 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/common.py +115 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/status.py +32 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/vacuum.py +81 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q7/__init__.py +136 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q7/clean_summary.py +75 -0
- roborock_cli/_vendor/roborock/devices/traits/traits_mixin.py +64 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/__init__.py +344 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/child_lock.py +29 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/clean_summary.py +83 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/command.py +38 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/common.py +172 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/consumeable.py +48 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/device_features.py +74 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/do_not_disturb.py +41 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/dust_collection_mode.py +13 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/flow_led_status.py +29 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/home.py +285 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/led_status.py +43 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/map_content.py +83 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/maps.py +80 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/network_info.py +55 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/rooms.py +105 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/routines.py +26 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/smart_wash_params.py +13 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/status.py +101 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/valley_electricity_timer.py +44 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/volume.py +27 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/wash_towel_mode.py +13 -0
- roborock_cli/_vendor/roborock/devices/transport/__init__.py +8 -0
- roborock_cli/_vendor/roborock/devices/transport/channel.py +32 -0
- roborock_cli/_vendor/roborock/devices/transport/local_channel.py +295 -0
- roborock_cli/_vendor/roborock/devices/transport/mqtt_channel.py +118 -0
- roborock_cli/_vendor/roborock/diagnostics.py +166 -0
- roborock_cli/_vendor/roborock/exceptions.py +95 -0
- roborock_cli/_vendor/roborock/map/__init__.py +7 -0
- roborock_cli/_vendor/roborock/map/map_parser.py +123 -0
- roborock_cli/_vendor/roborock/mqtt/__init__.py +10 -0
- roborock_cli/_vendor/roborock/mqtt/health_manager.py +60 -0
- roborock_cli/_vendor/roborock/mqtt/roborock_session.py +463 -0
- roborock_cli/_vendor/roborock/mqtt/session.py +108 -0
- roborock_cli/_vendor/roborock/protocol.py +558 -0
- roborock_cli/_vendor/roborock/protocols/__init__.py +3 -0
- roborock_cli/_vendor/roborock/protocols/a01_protocol.py +74 -0
- roborock_cli/_vendor/roborock/protocols/b01_q10_protocol.py +87 -0
- roborock_cli/_vendor/roborock/protocols/b01_q7_protocol.py +81 -0
- roborock_cli/_vendor/roborock/protocols/v1_protocol.py +271 -0
- roborock_cli/_vendor/roborock/py.typed +0 -0
- roborock_cli/_vendor/roborock/roborock_message.py +246 -0
- roborock_cli/_vendor/roborock/roborock_typing.py +382 -0
- roborock_cli/_vendor/roborock/util.py +54 -0
- roborock_cli/_vendor/roborock/web_api.py +761 -0
- roborock_cli/cli.py +715 -0
- roborock_cli/connection.py +202 -0
- roborock_cli/helpers.py +71 -0
- roborock_cli/server.py +759 -0
- roborock_cli/setup_auth.py +92 -0
- roborock_cli-0.1.1.dist-info/METADATA +172 -0
- roborock_cli-0.1.1.dist-info/RECORD +106 -0
- roborock_cli-0.1.1.dist-info/WHEEL +4 -0
- roborock_cli-0.1.1.dist-info/entry_points.txt +2 -0
- 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
|