Skip to content

Commit 55e75d6

Browse files
committed
feat: Add initial Q10 support
1 parent 7e40857 commit 55e75d6

27 files changed

+647
-37
lines changed

roborock/cli.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343

4444
from roborock import SHORT_MODEL_TO_ENUM, RoborockCommand
4545
from roborock.data import DeviceData, RoborockBase, UserData
46+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
4647
from roborock.device_features import DeviceFeatures
4748
from roborock.devices.cache import Cache, CacheData
4849
from roborock.devices.device import RoborockDevice
@@ -91,7 +92,12 @@ def wrapper(*args, **kwargs):
9192
context: RoborockContext = ctx.obj
9293

9394
async def run():
94-
return await func(*args, **kwargs)
95+
try:
96+
await func(*args, **kwargs)
97+
except Exception:
98+
_LOGGER.exception("Uncaught exception in command")
99+
click.echo(f"Error: {sys.exc_info()[1]}", err=True)
100+
await context.cleanup()
95101

96102
if context.is_session_mode():
97103
# Session mode - run in the persistent loop
@@ -739,6 +745,16 @@ async def network_info(ctx, device_id: str):
739745
await _display_v1_trait(context, device_id, lambda v1: v1.network_info)
740746

741747

748+
def _parse_b01_q10_command(cmd: str) -> B01_Q10_DP | None:
749+
"""Parse B01_Q10 command from either enum name or value."""
750+
for func in (B01_Q10_DP.from_code, B01_Q10_DP.from_name, B01_Q10_DP.from_value):
751+
try:
752+
return func(cmd)
753+
except ValueError:
754+
continue
755+
return None
756+
757+
742758
@click.command()
743759
@click.option("--device_id", required=True)
744760
@click.option("--cmd", required=True)
@@ -749,12 +765,20 @@ async def command(ctx, cmd, device_id, params):
749765
context: RoborockContext = ctx.obj
750766
device_manager = await context.get_device_manager()
751767
device = await device_manager.get_device(device_id)
752-
if device.v1_properties is None:
753-
raise RoborockException(f"Device {device.name} does not support V1 protocol")
754-
command_trait: Trait = device.v1_properties.command
755-
result = await command_trait.send(cmd, json.loads(params) if params is not None else None)
756-
if result:
757-
click.echo(dump_json(result))
768+
if device.v1_properties is not None:
769+
command_trait: Trait = device.v1_properties.command
770+
result = await command_trait.send(cmd, json.loads(params) if params is not None else {})
771+
if result:
772+
click.echo(dump_json(result))
773+
elif device.b01_q10_properties is not None:
774+
# Parse B01_Q10_DP from either enum name or the value
775+
if (cmd_value := _parse_b01_q10_command(cmd)) is None:
776+
raise RoborockException(f"Invalid command {cmd} for B01_Q10 device")
777+
await device.b01_q10_properties.send(cmd_value, json.loads(params) if params is not None else {})
778+
# B10 Commands don't have a specific time to respond, so wait a bit
779+
await asyncio.sleep(5)
780+
else:
781+
raise RoborockException(f"Device {device.name} does not support sending raw commands")
758782

759783

760784
@click.command()

roborock/data/code_mappings.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,28 @@ def __new__(cls, value: str, code: int) -> RoborockModeEnum:
6565

6666
@classmethod
6767
def from_code(cls, code: int):
68+
"""Find enum member by code."""
6869
for member in cls:
6970
if member.code == code:
7071
return member
7172
raise ValueError(f"{code} is not a valid code for {cls.__name__}")
7273

74+
@classmethod
75+
def from_name(cls, name: str):
76+
"""Find enum member by name (case-insensitive)."""
77+
for member in cls:
78+
if member.name.lower() == name.lower():
79+
return member
80+
raise ValueError(f"{name} is not a valid name for {cls.__name__}")
81+
82+
@classmethod
83+
def from_value(cls, value: str):
84+
"""Find enum member by value (case-insensitive)."""
85+
for member in cls:
86+
if member.value.lower() == value.lower():
87+
return member
88+
raise ValueError(f"{value} is not a valid value for {cls.__name__}")
89+
7390
@classmethod
7491
def keys(cls) -> list[str]:
7592
"""Returns a list of all member values."""
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Thin wrapper around the MQTT channel for Roborock B01 devices."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from collections.abc import AsyncGenerator
7+
from typing import Any
8+
9+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
10+
from roborock.exceptions import RoborockException
11+
from roborock.protocols.b01_q10_protocol import (
12+
ParamsType,
13+
decode_rpc_response,
14+
encode_mqtt_payload,
15+
)
16+
17+
from .mqtt_channel import MqttChannel
18+
19+
_LOGGER = logging.getLogger(__name__)
20+
_TIMEOUT = 10.0
21+
22+
23+
async def send_command(
24+
mqtt_channel: MqttChannel,
25+
command: B01_Q10_DP,
26+
params: ParamsType,
27+
) -> None:
28+
"""Send a command on the MQTT channel, without waiting for a response"""
29+
_LOGGER.debug(
30+
"Sending B01 MQTT command: cmd=%s params=%s",
31+
command,
32+
params,
33+
)
34+
roborock_message = encode_mqtt_payload(command, params)
35+
_LOGGER.debug("Sending MQTT message: %s", roborock_message)
36+
try:
37+
await mqtt_channel.publish(roborock_message)
38+
except RoborockException as ex:
39+
_LOGGER.debug(
40+
"Error sending B01 decoded command (method=%s params=%s): %s",
41+
command,
42+
params,
43+
ex,
44+
)
45+
raise
46+
47+
48+
async def stream_decoded_responses(
49+
mqtt_channel: MqttChannel,
50+
) -> AsyncGenerator[dict[B01_Q10_DP, Any], None]:
51+
"""Stream decoded DPS messages received via MQTT."""
52+
53+
async for response_message in mqtt_channel.subscribe_stream():
54+
try:
55+
decoded_dps = decode_rpc_response(response_message)
56+
except RoborockException as ex:
57+
_LOGGER.debug(
58+
"Failed to decode B01 RPC response: %s: %s",
59+
response_message,
60+
ex,
61+
)
62+
continue
63+
yield decoded_dps
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import Any
99

1010
from roborock.exceptions import RoborockException
11-
from roborock.protocols.b01_protocol import (
11+
from roborock.protocols.b01_q7_protocol import (
1212
CommandType,
1313
ParamsType,
1414
decode_rpc_response,

roborock/devices/device.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,12 +195,14 @@ async def connect(self) -> None:
195195
if self._unsub:
196196
raise ValueError("Already connected to the device")
197197
unsub = await self._channel.subscribe(self._on_message)
198-
if self.v1_properties is not None:
199-
try:
198+
try:
199+
if self.v1_properties is not None:
200200
await self.v1_properties.discover_features()
201-
except RoborockException:
202-
unsub()
203-
raise
201+
elif self.b01_q10_properties is not None:
202+
await self.b01_q10_properties.start()
203+
except RoborockException:
204+
unsub()
205+
raise
204206
self._logger.info("Connected to device")
205207
self._unsub = unsub
206208

@@ -212,6 +214,8 @@ async def close(self) -> None:
212214
await self._connect_task
213215
except asyncio.CancelledError:
214216
pass
217+
if self.b01_q10_properties is not None:
218+
await self.b01_q10_properties.close()
215219
if self._unsub:
216220
self._unsub()
217221
self._unsub = None

roborock/devices/device_manager.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,7 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
240240
channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
241241
model_part = product.model.split(".")[-1]
242242
if "ss" in model_part:
243-
raise UnsupportedDeviceError(
244-
f"Device {device.name} has unsupported version B01 product model {product.model}"
245-
)
243+
trait = b01.q10.create(channel)
246244
elif "sc" in model_part:
247245
# Q7 devices start with 'sc' in their model naming.
248246
trait = b01.q7.create(channel)

roborock/devices/mqtt_channel.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Modules for communicating with specific Roborock devices over MQTT."""
22

3+
import asyncio
34
import logging
4-
from collections.abc import Callable
5+
from collections.abc import AsyncGenerator, Callable
56

67
from roborock.callbacks import decoder_callback
78
from roborock.data import HomeDataDevice, RRiot, UserData
@@ -73,6 +74,21 @@ async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callab
7374
dispatch = decoder_callback(self._decoder, callback, _LOGGER)
7475
return await self._mqtt_session.subscribe(self._subscribe_topic, dispatch)
7576

77+
async def subscribe_stream(self) -> AsyncGenerator[RoborockMessage, None]:
78+
"""Subscribe to the device's message stream.
79+
80+
This is useful for processing all incoming messages in an async for loop,
81+
when they are not necessarily associated with a specific request.
82+
"""
83+
message_queue: asyncio.Queue[RoborockMessage] = asyncio.Queue()
84+
unsub = await self.subscribe(message_queue.put_nowait)
85+
try:
86+
while True:
87+
message = await message_queue.get()
88+
yield message
89+
finally:
90+
unsub()
91+
7692
async def publish(self, message: RoborockMessage) -> None:
7793
"""Publish a command message.
7894
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
"""Traits for B01 devices."""
22

33
from .q7 import Q7PropertiesApi
4+
from .q10 import Q10PropertiesApi
45

5-
__all__ = ["Q7PropertiesApi", "q7", "q10"]
6+
__all__ = [
7+
"Q7PropertiesApi",
8+
"Q10PropertiesApi",
9+
"q7",
10+
"q10",
11+
]
Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,104 @@
1-
"""Q10"""
1+
"""Traits for Q10 B01 devices."""
2+
3+
import asyncio
4+
import logging
5+
from typing import Any
6+
7+
from roborock import B01Props
8+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
9+
from roborock.devices.b01_q10_channel import ParamsType, send_command, stream_decoded_responses
10+
from roborock.devices.mqtt_channel import MqttChannel
11+
from roborock.devices.traits import Trait
12+
13+
_LOGGER = logging.getLogger(__name__)
14+
15+
__all__ = [
16+
"Q10PropertiesApi",
17+
]
18+
19+
20+
class Q10PropertiesApi(Trait):
21+
"""API for interacting with B01 devices."""
22+
23+
def __init__(self, channel: MqttChannel) -> None:
24+
"""Initialize the B01Props API."""
25+
self._channel = channel
26+
self._task: asyncio.Task | None = None
27+
28+
async def start(self) -> None:
29+
"""Start any necessary subscriptions for the trait."""
30+
self._task = asyncio.create_task(self._run_loop())
31+
32+
async def close(self) -> None:
33+
"""Close any resources held by the trait."""
34+
if self._task is not None:
35+
self._task.cancel()
36+
try:
37+
await self._task
38+
except asyncio.CancelledError:
39+
pass
40+
self._task = None
41+
42+
async def start_clean(self) -> None:
43+
"""Start cleaning."""
44+
await self.send(
45+
command=B01_Q10_DP.START_CLEAN,
46+
# TODO: figure out other commands
47+
# 1 = start cleaning
48+
# 2 = electoral clean, also has "clean_paramters"
49+
# 4 = fast create map
50+
params={"cmd": 1},
51+
)
52+
53+
async def pause_clean(self) -> None:
54+
"""Pause cleaning."""
55+
await self.send(
56+
command=B01_Q10_DP.PAUSE,
57+
params={},
58+
)
59+
60+
async def resume_clean(self) -> None:
61+
"""Pause cleaning."""
62+
await self.send(
63+
command=B01_Q10_DP.RESUME,
64+
params={},
65+
)
66+
67+
async def stop_clean(self) -> None:
68+
"""Stop cleaning."""
69+
await self.send(
70+
command=B01_Q10_DP.STOP,
71+
params={},
72+
)
73+
74+
async def return_to_dock(self) -> None:
75+
"""Return to dock."""
76+
await self.send(
77+
command=B01_Q10_DP.START_DOCK_TASK,
78+
params={},
79+
)
80+
81+
async def send(self, command: B01_Q10_DP, params: ParamsType) -> None:
82+
"""Send a command to the device."""
83+
await send_command(
84+
self._channel,
85+
command=command,
86+
params=params,
87+
)
88+
89+
async def _run_loop(self) -> None:
90+
"""Run the main loop for processing incoming messages."""
91+
async for decoded_dps in stream_decoded_responses(self._channel):
92+
_LOGGER.debug("Received B01 Q10 decoded DPS: %s", decoded_dps)
93+
94+
# Temporary debugging: Log all common values
95+
if B01_Q10_DP.COMMON not in decoded_dps:
96+
continue
97+
common_values = decoded_dps[B01_Q10_DP.COMMON]
98+
for key, value in common_values.items():
99+
_LOGGER.debug("%s: %s", key, value)
100+
101+
102+
def create(channel: MqttChannel) -> Q10PropertiesApi:
103+
"""Create traits for B01 devices."""
104+
return Q10PropertiesApi(channel)

roborock/devices/traits/b01/q7/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
SCWindMapping,
1111
WaterLevelMapping,
1212
)
13-
from roborock.devices.b01_channel import CommandType, ParamsType, send_decoded_command
13+
from roborock.devices.b01_q7_channel import CommandType, ParamsType, send_decoded_command
1414
from roborock.devices.mqtt_channel import MqttChannel
1515
from roborock.devices.traits import Trait
1616
from roborock.roborock_message import RoborockB01Props

0 commit comments

Comments
 (0)