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,76 @@
|
|
|
1
|
+
"""Traits for Q10 B01 devices."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from roborock_cli._vendor.roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
|
|
8
|
+
from roborock_cli._vendor.roborock.devices.rpc.b01_q10_channel import stream_decoded_responses
|
|
9
|
+
from roborock_cli._vendor.roborock.devices.traits import Trait
|
|
10
|
+
from roborock_cli._vendor.roborock.devices.transport.mqtt_channel import MqttChannel
|
|
11
|
+
|
|
12
|
+
from .command import CommandTrait
|
|
13
|
+
from .status import StatusTrait
|
|
14
|
+
from .vacuum import VacuumTrait
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Q10PropertiesApi",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
_LOGGER = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Q10PropertiesApi(Trait):
|
|
24
|
+
"""API for interacting with B01 devices."""
|
|
25
|
+
|
|
26
|
+
command: CommandTrait
|
|
27
|
+
"""Trait for sending commands to Q10 devices."""
|
|
28
|
+
|
|
29
|
+
status: StatusTrait
|
|
30
|
+
"""Trait for managing the status of Q10 devices."""
|
|
31
|
+
|
|
32
|
+
vacuum: VacuumTrait
|
|
33
|
+
"""Trait for sending vacuum related commands to Q10 devices."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, channel: MqttChannel) -> None:
|
|
36
|
+
"""Initialize the B01Props API."""
|
|
37
|
+
self._channel = channel
|
|
38
|
+
self.command = CommandTrait(channel)
|
|
39
|
+
self.vacuum = VacuumTrait(self.command)
|
|
40
|
+
self.status = StatusTrait()
|
|
41
|
+
self._subscribe_task: asyncio.Task[None] | None = None
|
|
42
|
+
|
|
43
|
+
async def start(self) -> None:
|
|
44
|
+
"""Start any necessary subscriptions for the trait."""
|
|
45
|
+
self._subscribe_task = asyncio.create_task(self._subscribe_loop())
|
|
46
|
+
|
|
47
|
+
async def close(self) -> None:
|
|
48
|
+
"""Close any resources held by the trait."""
|
|
49
|
+
if self._subscribe_task is not None:
|
|
50
|
+
self._subscribe_task.cancel()
|
|
51
|
+
try:
|
|
52
|
+
await self._subscribe_task
|
|
53
|
+
except asyncio.CancelledError:
|
|
54
|
+
pass # ignore cancellation errors
|
|
55
|
+
self._subscribe_task = None
|
|
56
|
+
|
|
57
|
+
async def refresh(self) -> None:
|
|
58
|
+
"""Refresh all traits."""
|
|
59
|
+
# Sending the REQUEST_DPS will cause the device to send all DPS values
|
|
60
|
+
# to the device. Updates will be received by the subscribe loop below.
|
|
61
|
+
await self.command.send(B01_Q10_DP.REQUEST_DPS, params={})
|
|
62
|
+
|
|
63
|
+
async def _subscribe_loop(self) -> None:
|
|
64
|
+
"""Persistent loop to listen for status updates."""
|
|
65
|
+
async for decoded_dps in stream_decoded_responses(self._channel):
|
|
66
|
+
_LOGGER.debug("Received Q10 status update: %s", decoded_dps)
|
|
67
|
+
|
|
68
|
+
# Notify all traits about a new message and each trait will
|
|
69
|
+
# only update what fields that it is responsible for.
|
|
70
|
+
# More traits can be added here below.
|
|
71
|
+
self.status.update_from_dps(decoded_dps)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def create(channel: MqttChannel) -> Q10PropertiesApi:
|
|
75
|
+
"""Create traits for B01 devices."""
|
|
76
|
+
return Q10PropertiesApi(channel)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from roborock_cli._vendor.roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
|
|
4
|
+
from roborock_cli._vendor.roborock.devices.rpc.b01_q10_channel import send_command
|
|
5
|
+
from roborock_cli._vendor.roborock.devices.transport.mqtt_channel import MqttChannel
|
|
6
|
+
from roborock_cli._vendor.roborock.protocols.b01_q10_protocol import ParamsType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CommandTrait:
|
|
10
|
+
"""Trait for sending commands to Q10 Roborock devices.
|
|
11
|
+
|
|
12
|
+
This trait allows sending raw commands directly to the device. It is particularly
|
|
13
|
+
useful for accessing features that do not have their own traits. Generally
|
|
14
|
+
it is preferred to use specific traits for device functionality when
|
|
15
|
+
available.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, channel: MqttChannel) -> None:
|
|
19
|
+
"""Initialize the CommandTrait."""
|
|
20
|
+
self._channel = channel
|
|
21
|
+
|
|
22
|
+
async def send(self, command: B01_Q10_DP, params: ParamsType = None) -> Any:
|
|
23
|
+
"""Send a command to the device.
|
|
24
|
+
|
|
25
|
+
Sending a raw command to the device using this method does not update
|
|
26
|
+
the internal state of any other traits. It is the responsibility of the
|
|
27
|
+
caller to ensure that any traits affected by the command are refreshed
|
|
28
|
+
as needed.
|
|
29
|
+
"""
|
|
30
|
+
if not self._channel:
|
|
31
|
+
raise ValueError("Device trait in invalid state")
|
|
32
|
+
return await send_command(self._channel, command, params=params)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Common utilities for Q10 traits.
|
|
2
|
+
|
|
3
|
+
This module provides infrastructure for mapping Roborock Data Points (DPS) to
|
|
4
|
+
Python dataclass fields and handling the lifecycle of data updates from the
|
|
5
|
+
device.
|
|
6
|
+
|
|
7
|
+
### DPS Metadata Annotation
|
|
8
|
+
|
|
9
|
+
Classes extending `RoborockBase` can annotate their fields with DPS IDs using
|
|
10
|
+
the `field(metadata={"dps": ...})` convention. This creates a declarative
|
|
11
|
+
mapping that `DpsDataConverter` uses to automatically route incoming device
|
|
12
|
+
data to the correct attribute.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
@dataclass
|
|
18
|
+
class MyStatus(RoborockBase):
|
|
19
|
+
battery: int = field(metadata={"dps": B01_Q10_DP.BATTERY})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Update Lifecycle
|
|
23
|
+
1. **Raw Data**: The device sends encoded DPS updates over MQTT.
|
|
24
|
+
2. **Decoding**: The transport layer decodes these into a dictionary (e.g., `{"101": 80}`).
|
|
25
|
+
3. **Conversion**: `DpsDataConverter` uses `RoborockBase.convert_dict` to transform
|
|
26
|
+
raw values into appropriate Python types (e.g., Enums, ints) based on the
|
|
27
|
+
dataclass field types.
|
|
28
|
+
4. **Update**: `update_from_dps` maps these converted values to field names and
|
|
29
|
+
updates the target object using `setattr`.
|
|
30
|
+
|
|
31
|
+
### Usage
|
|
32
|
+
|
|
33
|
+
Typically, a trait will instantiate a single `DpsDataConverter` for its status class
|
|
34
|
+
and call `update_from_dps` whenever new data is received from the device stream.
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import dataclasses
|
|
39
|
+
import logging
|
|
40
|
+
from collections.abc import Callable
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
from roborock_cli._vendor.roborock.callbacks import CallbackList
|
|
44
|
+
from roborock_cli._vendor.roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
|
|
45
|
+
from roborock_cli._vendor.roborock.data.containers import RoborockBase
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TraitUpdateListener:
|
|
49
|
+
"""Trait update listener.
|
|
50
|
+
|
|
51
|
+
This is a base class for traits to support notifying listeners when they
|
|
52
|
+
have been updated. Clients may register callbacks to be notified when the
|
|
53
|
+
trait has been updated. When the listener callback is invoked, the client
|
|
54
|
+
should read the trait's properties to get the updated values.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, logger: logging.Logger) -> None:
|
|
58
|
+
"""Initialize the trait update listener."""
|
|
59
|
+
self._update_callbacks: CallbackList[None] = CallbackList(logger=logger)
|
|
60
|
+
|
|
61
|
+
def add_update_listener(self, callback: Callable[[], None]) -> Callable[[], None]:
|
|
62
|
+
"""Register a callback when the trait has been updated.
|
|
63
|
+
|
|
64
|
+
Returns a callable to remove the listener.
|
|
65
|
+
"""
|
|
66
|
+
# We wrap the callback to ignore the value passed to it.
|
|
67
|
+
return self._update_callbacks.add_callback(lambda _: callback())
|
|
68
|
+
|
|
69
|
+
def _notify_update(self) -> None:
|
|
70
|
+
"""Notify all update listeners."""
|
|
71
|
+
self._update_callbacks(None)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DpsDataConverter:
|
|
75
|
+
"""Utility to handle the transformation and merging of DPS data into models.
|
|
76
|
+
|
|
77
|
+
This class pre-calculates the mapping between Data Point IDs and dataclass fields
|
|
78
|
+
to optimize repeated updates from device streams.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, dps_type_map: dict[B01_Q10_DP, type], dps_field_map: dict[B01_Q10_DP, str]):
|
|
82
|
+
"""Initialize the converter for a specific RoborockBase-derived class."""
|
|
83
|
+
self._dps_type_map = dps_type_map
|
|
84
|
+
self._dps_field_map = dps_field_map
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_dataclass(cls, dataclass_type: type[RoborockBase]):
|
|
88
|
+
"""Initialize the converter for a specific RoborockBase-derived class."""
|
|
89
|
+
dps_type_map: dict[B01_Q10_DP, type] = {}
|
|
90
|
+
dps_field_map: dict[B01_Q10_DP, str] = {}
|
|
91
|
+
for field_obj in dataclasses.fields(dataclass_type):
|
|
92
|
+
if field_obj.metadata and "dps" in field_obj.metadata:
|
|
93
|
+
dps_id = field_obj.metadata["dps"]
|
|
94
|
+
dps_type_map[dps_id] = field_obj.type
|
|
95
|
+
dps_field_map[dps_id] = field_obj.name
|
|
96
|
+
return cls(dps_type_map, dps_field_map)
|
|
97
|
+
|
|
98
|
+
def update_from_dps(self, target: RoborockBase, decoded_dps: dict[B01_Q10_DP, Any]) -> bool:
|
|
99
|
+
"""Convert and merge raw DPS data into the target object.
|
|
100
|
+
|
|
101
|
+
Uses the pre-calculated type mapping to ensure values are converted to the
|
|
102
|
+
correct Python types before being updated on the target.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
target: The target object to update.
|
|
106
|
+
decoded_dps: The decoded DPS data to convert.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
True if any values were updated, False otherwise.
|
|
110
|
+
"""
|
|
111
|
+
conversions = RoborockBase.convert_dict(self._dps_type_map, decoded_dps)
|
|
112
|
+
for dps_id, value in conversions.items():
|
|
113
|
+
field_name = self._dps_field_map[dps_id]
|
|
114
|
+
setattr(target, field_name, value)
|
|
115
|
+
return bool(conversions)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Status trait for Q10 B01 devices."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from roborock_cli._vendor.roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
|
|
7
|
+
from roborock_cli._vendor.roborock.data.b01_q10.b01_q10_containers import Q10Status
|
|
8
|
+
|
|
9
|
+
from .common import DpsDataConverter, TraitUpdateListener
|
|
10
|
+
|
|
11
|
+
_LOGGER = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_CONVERTER = DpsDataConverter.from_dataclass(Q10Status)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StatusTrait(Q10Status, TraitUpdateListener):
|
|
17
|
+
"""Trait for managing the status of Q10 Roborock devices.
|
|
18
|
+
|
|
19
|
+
This is a thin wrapper around Q10Status that provides the Trait interface.
|
|
20
|
+
The current values reflect the most recently received data from the device.
|
|
21
|
+
New values can be requested through the `Q10PropertiesApi`'s `refresh` method.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
"""Initialize the status trait."""
|
|
26
|
+
super().__init__()
|
|
27
|
+
TraitUpdateListener.__init__(self, logger=_LOGGER)
|
|
28
|
+
|
|
29
|
+
def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
|
|
30
|
+
"""Update the trait from raw DPS data."""
|
|
31
|
+
if _CONVERTER.update_from_dps(self, decoded_dps):
|
|
32
|
+
self._notify_update()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Traits for Q10 B01 devices."""
|
|
2
|
+
|
|
3
|
+
from roborock_cli._vendor.roborock.data.b01_q10.b01_q10_code_mappings import (
|
|
4
|
+
B01_Q10_DP,
|
|
5
|
+
YXCleanType,
|
|
6
|
+
YXFanLevel,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
from .command import CommandTrait
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VacuumTrait:
|
|
13
|
+
"""Trait for sending vacuum commands.
|
|
14
|
+
|
|
15
|
+
This is a wrapper around the CommandTrait for sending vacuum related
|
|
16
|
+
commands to Q10 devices.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, command: CommandTrait) -> None:
|
|
20
|
+
"""Initialize the VacuumTrait."""
|
|
21
|
+
self._command = command
|
|
22
|
+
|
|
23
|
+
async def start_clean(self) -> None:
|
|
24
|
+
"""Start cleaning."""
|
|
25
|
+
await self._command.send(
|
|
26
|
+
command=B01_Q10_DP.START_CLEAN,
|
|
27
|
+
# TODO: figure out other commands
|
|
28
|
+
# 1 = start cleaning
|
|
29
|
+
# 2 = "electoral" clean, also has "clean_parameters"
|
|
30
|
+
# 4 = fast create map
|
|
31
|
+
params={"cmd": 1},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
async def pause_clean(self) -> None:
|
|
35
|
+
"""Pause cleaning."""
|
|
36
|
+
await self._command.send(
|
|
37
|
+
command=B01_Q10_DP.PAUSE,
|
|
38
|
+
params={},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
async def resume_clean(self) -> None:
|
|
42
|
+
"""Resume cleaning."""
|
|
43
|
+
await self._command.send(
|
|
44
|
+
command=B01_Q10_DP.RESUME,
|
|
45
|
+
params={},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def stop_clean(self) -> None:
|
|
49
|
+
"""Stop cleaning."""
|
|
50
|
+
await self._command.send(
|
|
51
|
+
command=B01_Q10_DP.STOP,
|
|
52
|
+
params={},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async def return_to_dock(self) -> None:
|
|
56
|
+
"""Return to dock."""
|
|
57
|
+
await self._command.send(
|
|
58
|
+
command=B01_Q10_DP.START_DOCK_TASK,
|
|
59
|
+
params={},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def empty_dustbin(self) -> None:
|
|
63
|
+
"""Empty the dustbin at the dock."""
|
|
64
|
+
await self._command.send(
|
|
65
|
+
command=B01_Q10_DP.START_DOCK_TASK,
|
|
66
|
+
params=2, # 2 = dock task type for "empty dustbin"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def set_clean_mode(self, mode: YXCleanType) -> None:
|
|
70
|
+
"""Set the cleaning mode (vacuum, mop, or both)."""
|
|
71
|
+
await self._command.send(
|
|
72
|
+
command=B01_Q10_DP.CLEAN_MODE,
|
|
73
|
+
params=mode.code,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def set_fan_level(self, level: YXFanLevel) -> None:
|
|
77
|
+
"""Set the fan suction level."""
|
|
78
|
+
await self._command.send(
|
|
79
|
+
command=B01_Q10_DP.FAN_LEVEL,
|
|
80
|
+
params=level.code,
|
|
81
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Traits for Q7 B01 devices.
|
|
2
|
+
Potentially other devices may fall into this category in the future."""
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from roborock_cli._vendor.roborock import B01Props
|
|
7
|
+
from roborock_cli._vendor.roborock.data.b01_q7.b01_q7_code_mappings import (
|
|
8
|
+
CleanPathPreferenceMapping,
|
|
9
|
+
CleanRepeatMapping,
|
|
10
|
+
CleanTaskTypeMapping,
|
|
11
|
+
CleanTypeMapping,
|
|
12
|
+
SCDeviceCleanParam,
|
|
13
|
+
SCWindMapping,
|
|
14
|
+
WaterLevelMapping,
|
|
15
|
+
)
|
|
16
|
+
from roborock_cli._vendor.roborock.devices.rpc.b01_q7_channel import send_decoded_command
|
|
17
|
+
from roborock_cli._vendor.roborock.devices.traits import Trait
|
|
18
|
+
from roborock_cli._vendor.roborock.devices.transport.mqtt_channel import MqttChannel
|
|
19
|
+
from roborock_cli._vendor.roborock.protocols.b01_q7_protocol import CommandType, ParamsType, Q7RequestMessage
|
|
20
|
+
from roborock_cli._vendor.roborock.roborock_message import RoborockB01Props
|
|
21
|
+
from roborock_cli._vendor.roborock.roborock_typing import RoborockB01Q7Methods
|
|
22
|
+
|
|
23
|
+
from .clean_summary import CleanSummaryTrait
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Q7PropertiesApi",
|
|
27
|
+
"CleanSummaryTrait",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Q7PropertiesApi(Trait):
|
|
32
|
+
"""API for interacting with B01 devices."""
|
|
33
|
+
|
|
34
|
+
clean_summary: CleanSummaryTrait
|
|
35
|
+
"""Trait for clean records / clean summary (Q7 `service.get_record_list`)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, channel: MqttChannel) -> None:
|
|
38
|
+
"""Initialize the B01Props API."""
|
|
39
|
+
self._channel = channel
|
|
40
|
+
self.clean_summary = CleanSummaryTrait(channel)
|
|
41
|
+
|
|
42
|
+
async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
|
|
43
|
+
"""Query the device for the values of the given Q7 properties."""
|
|
44
|
+
result = await self.send(
|
|
45
|
+
RoborockB01Q7Methods.GET_PROP,
|
|
46
|
+
{"property": props},
|
|
47
|
+
)
|
|
48
|
+
if not isinstance(result, dict):
|
|
49
|
+
raise TypeError(f"Unexpected response type for GET_PROP: {type(result).__name__}: {result!r}")
|
|
50
|
+
return B01Props.from_dict(result)
|
|
51
|
+
|
|
52
|
+
async def set_prop(self, prop: RoborockB01Props, value: Any) -> None:
|
|
53
|
+
"""Set a property on the device."""
|
|
54
|
+
await self.send(
|
|
55
|
+
command=RoborockB01Q7Methods.SET_PROP,
|
|
56
|
+
params={prop: value},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async def set_fan_speed(self, fan_speed: SCWindMapping) -> None:
|
|
60
|
+
"""Set the fan speed (wind)."""
|
|
61
|
+
await self.set_prop(RoborockB01Props.WIND, fan_speed.code)
|
|
62
|
+
|
|
63
|
+
async def set_water_level(self, water_level: WaterLevelMapping) -> None:
|
|
64
|
+
"""Set the water level (water)."""
|
|
65
|
+
await self.set_prop(RoborockB01Props.WATER, water_level.code)
|
|
66
|
+
|
|
67
|
+
async def set_mode(self, mode: CleanTypeMapping) -> None:
|
|
68
|
+
"""Set the cleaning mode (vacuum, mop, or vacuum and mop)."""
|
|
69
|
+
await self.set_prop(RoborockB01Props.MODE, mode.code)
|
|
70
|
+
|
|
71
|
+
async def set_clean_path_preference(self, preference: CleanPathPreferenceMapping) -> None:
|
|
72
|
+
"""Set the cleaning path preference (route)."""
|
|
73
|
+
await self.set_prop(RoborockB01Props.CLEAN_PATH_PREFERENCE, preference.code)
|
|
74
|
+
|
|
75
|
+
async def set_repeat_state(self, repeat: CleanRepeatMapping) -> None:
|
|
76
|
+
"""Set the cleaning repeat state (cycles)."""
|
|
77
|
+
await self.set_prop(RoborockB01Props.REPEAT_STATE, repeat.code)
|
|
78
|
+
|
|
79
|
+
async def start_clean(self) -> None:
|
|
80
|
+
"""Start cleaning."""
|
|
81
|
+
await self.send(
|
|
82
|
+
command=RoborockB01Q7Methods.SET_ROOM_CLEAN,
|
|
83
|
+
params={
|
|
84
|
+
"clean_type": CleanTaskTypeMapping.ALL.code,
|
|
85
|
+
"ctrl_value": SCDeviceCleanParam.START.code,
|
|
86
|
+
"room_ids": [],
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def pause_clean(self) -> None:
|
|
91
|
+
"""Pause cleaning."""
|
|
92
|
+
await self.send(
|
|
93
|
+
command=RoborockB01Q7Methods.SET_ROOM_CLEAN,
|
|
94
|
+
params={
|
|
95
|
+
"clean_type": CleanTaskTypeMapping.ALL.code,
|
|
96
|
+
"ctrl_value": SCDeviceCleanParam.PAUSE.code,
|
|
97
|
+
"room_ids": [],
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async def stop_clean(self) -> None:
|
|
102
|
+
"""Stop cleaning."""
|
|
103
|
+
await self.send(
|
|
104
|
+
command=RoborockB01Q7Methods.SET_ROOM_CLEAN,
|
|
105
|
+
params={
|
|
106
|
+
"clean_type": CleanTaskTypeMapping.ALL.code,
|
|
107
|
+
"ctrl_value": SCDeviceCleanParam.STOP.code,
|
|
108
|
+
"room_ids": [],
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async def return_to_dock(self) -> None:
|
|
113
|
+
"""Return to dock."""
|
|
114
|
+
await self.send(
|
|
115
|
+
command=RoborockB01Q7Methods.START_RECHARGE,
|
|
116
|
+
params={},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def find_me(self) -> None:
|
|
120
|
+
"""Locate the robot."""
|
|
121
|
+
await self.send(
|
|
122
|
+
command=RoborockB01Q7Methods.FIND_DEVICE,
|
|
123
|
+
params={},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
async def send(self, command: CommandType, params: ParamsType) -> Any:
|
|
127
|
+
"""Send a command to the device."""
|
|
128
|
+
return await send_decoded_command(
|
|
129
|
+
self._channel,
|
|
130
|
+
Q7RequestMessage(dps=10000, command=command, params=params),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def create(channel: MqttChannel) -> Q7PropertiesApi:
|
|
135
|
+
"""Create traits for B01 devices."""
|
|
136
|
+
return Q7PropertiesApi(channel)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Clean summary / clean records trait for B01 Q7 devices.
|
|
2
|
+
|
|
3
|
+
For B01/Q7, the Roborock app uses `service.get_record_list` which returns totals
|
|
4
|
+
and a `record_list` whose items contain a JSON string in `detail`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from roborock_cli._vendor.roborock import CleanRecordDetail, CleanRecordList, CleanRecordSummary
|
|
12
|
+
from roborock_cli._vendor.roborock.devices.rpc.b01_q7_channel import send_decoded_command
|
|
13
|
+
from roborock_cli._vendor.roborock.devices.traits import Trait
|
|
14
|
+
from roborock_cli._vendor.roborock.devices.transport.mqtt_channel import MqttChannel
|
|
15
|
+
from roborock_cli._vendor.roborock.exceptions import RoborockException
|
|
16
|
+
from roborock_cli._vendor.roborock.protocols.b01_q7_protocol import Q7RequestMessage
|
|
17
|
+
from roborock_cli._vendor.roborock.roborock_typing import RoborockB01Q7Methods
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CleanSummaryTrait",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_LOGGER = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CleanSummaryTrait(CleanRecordSummary, Trait):
|
|
27
|
+
"""B01/Q7 clean summary + clean record access (via record list service)."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, channel: MqttChannel) -> None:
|
|
30
|
+
"""Initialize the clean summary trait.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
channel: MQTT channel used to communicate with the device.
|
|
34
|
+
"""
|
|
35
|
+
super().__init__()
|
|
36
|
+
self._channel = channel
|
|
37
|
+
|
|
38
|
+
async def refresh(self) -> None:
|
|
39
|
+
"""Refresh totals and last record detail from the device."""
|
|
40
|
+
record_list = await self._get_record_list()
|
|
41
|
+
|
|
42
|
+
self.total_time = record_list.total_time
|
|
43
|
+
self.total_area = record_list.total_area
|
|
44
|
+
self.total_count = record_list.total_count
|
|
45
|
+
|
|
46
|
+
details = await self._get_clean_record_details(record_list=record_list)
|
|
47
|
+
self.last_record_detail = details[0] if details else None
|
|
48
|
+
|
|
49
|
+
async def _get_record_list(self) -> CleanRecordList:
|
|
50
|
+
"""Fetch the raw device clean record list (`service.get_record_list`)."""
|
|
51
|
+
result = await send_decoded_command(
|
|
52
|
+
self._channel,
|
|
53
|
+
Q7RequestMessage(dps=10000, command=RoborockB01Q7Methods.GET_RECORD_LIST, params={}),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if not isinstance(result, dict):
|
|
57
|
+
raise TypeError(f"Unexpected response type for GET_RECORD_LIST: {type(result).__name__}: {result!r}")
|
|
58
|
+
return CleanRecordList.from_dict(result)
|
|
59
|
+
|
|
60
|
+
async def _get_clean_record_details(self, *, record_list: CleanRecordList) -> list[CleanRecordDetail]:
|
|
61
|
+
"""Return parsed record detail objects (newest-first)."""
|
|
62
|
+
details: list[CleanRecordDetail] = []
|
|
63
|
+
for item in record_list.record_list:
|
|
64
|
+
try:
|
|
65
|
+
parsed = item.detail_parsed
|
|
66
|
+
except RoborockException as ex:
|
|
67
|
+
# Rather than failing if something goes wrong here, we should fail and log to tell the user.
|
|
68
|
+
_LOGGER.debug("Failed to parse record detail: %s", ex)
|
|
69
|
+
continue
|
|
70
|
+
if parsed is not None:
|
|
71
|
+
details.append(parsed)
|
|
72
|
+
|
|
73
|
+
# The server returns the newest record at the end of record_list; reverse so newest is first (index 0).
|
|
74
|
+
details.reverse()
|
|
75
|
+
return details
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Holds device traits mixin and related code.
|
|
2
|
+
|
|
3
|
+
This holds the TraitsMixin class, which is used to provide accessors for
|
|
4
|
+
various device traits. Each trait is a class that encapsulates a specific
|
|
5
|
+
set of functionality for a device, such as controlling a vacuum or a mop.
|
|
6
|
+
|
|
7
|
+
The TraitsMixin holds traits across all protocol types. A trait is supported
|
|
8
|
+
if it is non-None.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, fields
|
|
12
|
+
from typing import get_args, get_origin
|
|
13
|
+
|
|
14
|
+
from . import Trait, a01, b01, v1
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"TraitsMixin",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(init=False)
|
|
22
|
+
class TraitsMixin:
|
|
23
|
+
"""Mixin to provide trait accessors."""
|
|
24
|
+
|
|
25
|
+
v1_properties: v1.PropertiesApi | None = None
|
|
26
|
+
"""V1 properties trait, if supported."""
|
|
27
|
+
|
|
28
|
+
dyad: a01.DyadApi | None = None
|
|
29
|
+
"""Dyad API, if supported."""
|
|
30
|
+
|
|
31
|
+
zeo: a01.ZeoApi | None = None
|
|
32
|
+
"""Zeo API, if supported."""
|
|
33
|
+
|
|
34
|
+
b01_q7_properties: b01.Q7PropertiesApi | None = None
|
|
35
|
+
"""B01 Q7 properties trait, if supported."""
|
|
36
|
+
|
|
37
|
+
b01_q10_properties: b01.Q10PropertiesApi | None = None
|
|
38
|
+
"""B01 Q10 properties trait, if supported."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, trait: Trait) -> None:
|
|
41
|
+
"""Initialize the TraitsMixin with the given trait.
|
|
42
|
+
|
|
43
|
+
This will populate the appropriate trait attributes based on the types
|
|
44
|
+
of the traits provided.
|
|
45
|
+
"""
|
|
46
|
+
for item in fields(self):
|
|
47
|
+
trait_type = _get_trait_type(item)
|
|
48
|
+
if trait_type is type(trait):
|
|
49
|
+
setattr(self, item.name, trait)
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_trait_type(item) -> type[Trait]:
|
|
54
|
+
"""Get the trait type from a dataclass field."""
|
|
55
|
+
if get_origin(item.type) is None:
|
|
56
|
+
raise ValueError(f"Trait {item.name} is not an optional type")
|
|
57
|
+
if (args := get_args(item.type)) is None:
|
|
58
|
+
raise ValueError(f"Trait {item.name} is not an optional type")
|
|
59
|
+
if len(args) != 2 or args[1] is not type(None):
|
|
60
|
+
raise ValueError(f"Trait {item.name} is not an optional type")
|
|
61
|
+
trait_type = args[0]
|
|
62
|
+
if not issubclass(trait_type, Trait):
|
|
63
|
+
raise ValueError(f"Trait {item.name} is not a Trait subclass")
|
|
64
|
+
return trait_type
|