Skip to content

Commit 86aeba1

Browse files
authored
feat(q10): add Roborock Q10 S5+ support with CLI commands (#766)
1 parent dfa2fb1 commit 86aeba1

File tree

6 files changed

+210
-14
lines changed

6 files changed

+210
-14
lines changed

docs/DEVICES.md

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ Cloud and Network.
1515
* **Washers (A01)**: Use `device.a01_properties` for Dyad/Zeo devices.
1616
* Use `await device.a01_properties.query_values([...])` to get state.
1717
* Use `await device.a01_properties.set_value(protocol, value)` to control.
18+
* **Vacuums (B01 Q10)**: Use `device.b01_q10_properties` for Q10 series devices.
19+
* Use `device.b01_q10_properties.vacuum` to access vacuum commands (start, pause, stop, dock, empty dustbin, set clean mode, set fan level).
20+
* Use `device.b01_q10_properties.command.send()` for raw DP commands.
21+
* **Vacuums (B01 Q7)**: Use `device.b01_q7_properties` for Q7 series devices.
1822

1923
## Background: Understanding Device Protocols
2024

@@ -26,7 +30,7 @@ Cloud and Network.
2630
|----------|----------------|------|-----------|--------------|-------|
2731
| **V1** (`pv=1.0`) | Most vacuum robots (S7, S8, Q5, Q7, etc.) ||| `V1Channel` with `RpcChannel` | Prefers local, falls back to MQTT |
2832
| **A01** (`pv=A01`) | Dyad, Zeo washers ||| `MqttChannel` + helpers | MQTT only, DPS protocol |
29-
| **B01** (`pv=B01`) | Some newer models ||| `MqttChannel` + helpers | MQTT only, DPS protocol |
33+
| **B01** (`pv=B01`) | Q7, Q10 series ||| `MqttChannel` + helpers | MQTT only, DPS protocol |
3034

3135
**Key Point:** The `DeviceManager` automatically detects the protocol version and creates the appropriate channel type. You don't need to handle this manually.
3236

@@ -47,7 +51,7 @@ graph TB
4751
subgraph "Device Types by Protocol"
4852
V1Dev[V1 Devices<br/>pv=1.0<br/>Most vacuums]
4953
A01Dev[A01 Devices<br/>pv=A01<br/>Dyad, Zeo]
50-
B01Dev[B01 Devices<br/>pv=B01<br/>Some models]
54+
B01Dev[B01 Devices<br/>pv=B01<br/>Q7, Q10 series]
5155
end
5256
5357
subgraph "Traits Layer"
@@ -148,7 +152,7 @@ graph TB
148152
|----------|-------------|---------------|--------------|----------|
149153
| **V1** (`pv=1.0`) | `V1Channel` with `RpcChannel` | ✅ Yes | Multi-strategy (Local → MQTT) | Most vacuum robots |
150154
| **A01** (`pv=A01`) | `MqttChannel` + helpers | ❌ No | Direct MQTT | Dyad, Zeo washers |
151-
| **B01** (`pv=B01`) | `MqttChannel` + helpers | ❌ No | Direct MQTT | Some newer models |
155+
| **B01** (`pv=B01`) | `MqttChannel` + helpers | ❌ No | Direct MQTT | Q7, Q10 series |
152156

153157
## Account Setup Internals
154158

@@ -249,7 +253,7 @@ sequenceDiagram
249253
RPC-->>App: Status
250254
```
251255

252-
#### A01/B01 Devices (Dyad, Zeo) - MQTT Only
256+
#### A01/B01 Devices (Dyad, Zeo, Q7, Q10) - MQTT Only
253257

254258
```mermaid
255259
sequenceDiagram
@@ -302,7 +306,7 @@ sequenceDiagram
302306
| **Local Support** | ✅ Yes, preferred | ❌ No |
303307
| **Fallback** | Local → MQTT | N/A |
304308
| **Connection** | Requires network info fetch | Direct MQTT |
305-
| **Examples** | Most vacuum robots | Dyad washers, Zeo models |
309+
| **Examples** | Most vacuum robots | Dyad washers, Zeo, Q7, Q10 |
306310

307311
### MQTT Connection (All Devices)
308312

@@ -510,7 +514,7 @@ Different device models use different protocol versions:
510514
|----------|---------|----------|
511515
| V1 | Most vacuum robots | JSON RPC with AES encryption |
512516
| A01 | Dyad, Zeo | DPS-based protocol |
513-
| B01 | Some newer models | DPS-based protocol |
517+
| B01 | Q7, Q10 series | DPS-based protocol |
514518
| L01 | Local protocol variant | Binary protocol negotiation |
515519

516520
The protocol layer handles encoding/decoding transparently based on the device's `pv` field.
@@ -577,11 +581,14 @@ roborock/
577581
│ | ├── b01_q10_channel.py # B01 Q10 protocol helpers
578582
│ | └── ...
579583
│ └── traits/ # High-level device-specific command traits
580-
│ └── v1/ # V1 device traits
581-
│ ├── __init__.py # Trait initialization
582-
│ ├── clean.py # Cleaning commands
583-
│ ├── map.py # Map management
584-
│ └── ...
584+
│ ├── v1/ # V1 device traits
585+
│ │ ├── __init__.py # Trait initialization
586+
│ │ ├── clean.py # Cleaning commands
587+
│ │ ├── map.py # Map management
588+
│ │ └── ...
589+
│ └── b01/ # B01 device traits
590+
│ ├── q10/ # Q10 series (vacuum, command)
591+
│ └── q7/ # Q7 series
585592
├── mqtt/ # MQTT session management
586593
│ ├── session.py # Base session interface
587594
│ └── roborock_session.py # MQTT session with idle timeout

roborock/cli.py

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,14 @@
4343

4444
from roborock import RoborockCommand
4545
from roborock.data import RoborockBase, UserData
46-
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
46+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXCleanType, YXFanLevel
4747
from roborock.data.code_mappings import SHORT_MODEL_TO_ENUM
4848
from roborock.device_features import DeviceFeatures
4949
from roborock.devices.cache import Cache, CacheData
5050
from roborock.devices.device import RoborockDevice
5151
from roborock.devices.device_manager import DeviceManager, UserParams, create_device_manager
5252
from roborock.devices.traits import Trait
53+
from roborock.devices.traits.b01.q10.vacuum import VacuumTrait
5354
from roborock.devices.traits.v1 import V1TraitMixin
5455
from roborock.devices.traits.v1.consumeable import ConsumableAttribute
5556
from roborock.devices.traits.v1.map_content import MapContentTrait
@@ -438,6 +439,15 @@ async def _display_v1_trait(context: RoborockContext, device_id: str, display_fu
438439
click.echo(dump_json(trait.as_dict()))
439440

440441

442+
async def _q10_vacuum_trait(context: RoborockContext, device_id: str) -> VacuumTrait:
443+
"""Get VacuumTrait from Q10 device."""
444+
device_manager = await context.get_device_manager()
445+
device = await device_manager.get_device(device_id)
446+
if device.b01_q10_properties is None:
447+
raise RoborockUnsupportedFeature("Device does not support B01 Q10 protocol. Is it a Q10?")
448+
return device.b01_q10_properties.vacuum
449+
450+
441451
@session.command()
442452
@click.option("--device_id", required=True)
443453
@click.pass_context
@@ -1172,6 +1182,154 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur
11721182
cli.add_command(network_info)
11731183

11741184

1185+
# --- Q10 session commands ---
1186+
1187+
1188+
@session.command()
1189+
@click.option("--device_id", required=True, help="Device ID")
1190+
@click.pass_context
1191+
@async_command
1192+
async def q10_vacuum_start(ctx: click.Context, device_id: str) -> None:
1193+
"""Start vacuum cleaning on Q10 device."""
1194+
context: RoborockContext = ctx.obj
1195+
try:
1196+
trait = await _q10_vacuum_trait(context, device_id)
1197+
await trait.start_clean()
1198+
click.echo("Starting vacuum cleaning...")
1199+
except RoborockUnsupportedFeature:
1200+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
1201+
except RoborockException as e:
1202+
click.echo(f"Error: {e}")
1203+
1204+
1205+
@session.command()
1206+
@click.option("--device_id", required=True, help="Device ID")
1207+
@click.pass_context
1208+
@async_command
1209+
async def q10_vacuum_pause(ctx: click.Context, device_id: str) -> None:
1210+
"""Pause vacuum cleaning on Q10 device."""
1211+
context: RoborockContext = ctx.obj
1212+
try:
1213+
trait = await _q10_vacuum_trait(context, device_id)
1214+
await trait.pause_clean()
1215+
click.echo("Pausing vacuum cleaning...")
1216+
except RoborockUnsupportedFeature:
1217+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
1218+
except RoborockException as e:
1219+
click.echo(f"Error: {e}")
1220+
1221+
1222+
@session.command()
1223+
@click.option("--device_id", required=True, help="Device ID")
1224+
@click.pass_context
1225+
@async_command
1226+
async def q10_vacuum_resume(ctx: click.Context, device_id: str) -> None:
1227+
"""Resume vacuum cleaning on Q10 device."""
1228+
context: RoborockContext = ctx.obj
1229+
try:
1230+
trait = await _q10_vacuum_trait(context, device_id)
1231+
await trait.resume_clean()
1232+
click.echo("Resuming vacuum cleaning...")
1233+
except RoborockUnsupportedFeature:
1234+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
1235+
except RoborockException as e:
1236+
click.echo(f"Error: {e}")
1237+
1238+
1239+
@session.command()
1240+
@click.option("--device_id", required=True, help="Device ID")
1241+
@click.pass_context
1242+
@async_command
1243+
async def q10_vacuum_stop(ctx: click.Context, device_id: str) -> None:
1244+
"""Stop vacuum cleaning on Q10 device."""
1245+
context: RoborockContext = ctx.obj
1246+
try:
1247+
trait = await _q10_vacuum_trait(context, device_id)
1248+
await trait.stop_clean()
1249+
click.echo("Stopping vacuum cleaning...")
1250+
except RoborockUnsupportedFeature:
1251+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
1252+
except RoborockException as e:
1253+
click.echo(f"Error: {e}")
1254+
1255+
1256+
@session.command()
1257+
@click.option("--device_id", required=True, help="Device ID")
1258+
@click.pass_context
1259+
@async_command
1260+
async def q10_vacuum_dock(ctx: click.Context, device_id: str) -> None:
1261+
"""Return vacuum to dock on Q10 device."""
1262+
context: RoborockContext = ctx.obj
1263+
try:
1264+
trait = await _q10_vacuum_trait(context, device_id)
1265+
await trait.return_to_dock()
1266+
click.echo("Returning vacuum to dock...")
1267+
except RoborockUnsupportedFeature:
1268+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
1269+
except RoborockException as e:
1270+
click.echo(f"Error: {e}")
1271+
1272+
1273+
@session.command()
1274+
@click.option("--device_id", required=True, help="Device ID")
1275+
@click.pass_context
1276+
@async_command
1277+
async def q10_empty_dustbin(ctx: click.Context, device_id: str) -> None:
1278+
"""Empty the dustbin at the dock on Q10 device."""
1279+
context: RoborockContext = ctx.obj
1280+
try:
1281+
trait = await _q10_vacuum_trait(context, device_id)
1282+
await trait.empty_dustbin()
1283+
click.echo("Emptying dustbin...")
1284+
except RoborockUnsupportedFeature:
1285+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
1286+
except RoborockException as e:
1287+
click.echo(f"Error: {e}")
1288+
1289+
1290+
@session.command()
1291+
@click.option("--device_id", required=True, help="Device ID")
1292+
@click.option("--mode", required=True, type=click.Choice(["bothwork", "onlysweep", "onlymop"]), help="Clean mode")
1293+
@click.pass_context
1294+
@async_command
1295+
async def q10_set_clean_mode(ctx: click.Context, device_id: str, mode: str) -> None:
1296+
"""Set the cleaning mode on Q10 device (vacuum, mop, or both)."""
1297+
context: RoborockContext = ctx.obj
1298+
try:
1299+
trait = await _q10_vacuum_trait(context, device_id)
1300+
clean_mode = YXCleanType.from_value(mode)
1301+
await trait.set_clean_mode(clean_mode)
1302+
click.echo(f"Clean mode set to {mode}")
1303+
except RoborockUnsupportedFeature:
1304+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
1305+
except RoborockException as e:
1306+
click.echo(f"Error: {e}")
1307+
1308+
1309+
@session.command()
1310+
@click.option("--device_id", required=True, help="Device ID")
1311+
@click.option(
1312+
"--level",
1313+
required=True,
1314+
type=click.Choice(["close", "quiet", "normal", "strong", "max", "super"]),
1315+
help='Fan suction level (one of "close", "quiet", "normal", "strong", "max", "super")',
1316+
)
1317+
@click.pass_context
1318+
@async_command
1319+
async def q10_set_fan_level(ctx: click.Context, device_id: str, level: str) -> None:
1320+
"""Set the fan suction level on Q10 device."""
1321+
context: RoborockContext = ctx.obj
1322+
try:
1323+
trait = await _q10_vacuum_trait(context, device_id)
1324+
fan_level = YXFanLevel.from_value(level)
1325+
await trait.set_fan_level(fan_level)
1326+
click.echo(f"Fan level set to {fan_level.value}")
1327+
except RoborockUnsupportedFeature:
1328+
click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
1329+
except RoborockException as e:
1330+
click.echo(f"Error: {e}")
1331+
1332+
11751333
def main():
11761334
return cli()
11771335

roborock/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
ROBOROCK_QREVO_MASTER = "roborock.vacuum.a117"
3434
ROBOROCK_QREVO_CURV = "roborock.vacuum.a135"
3535
ROBOROCK_Q8_MAX = "roborock.vacuum.a73"
36+
ROBOROCK_Q10 = "roborock.vacuum.ss07"
3637
ROBOROCK_G10S_PRO = "roborock.vacuum.a26"
3738
ROBOROCK_G20S_Ultra = "roborock.vacuum.a143" # cn saros_r10
3839
ROBOROCK_G10S = "roborock.vacuum.a46"
@@ -76,6 +77,7 @@
7677
ROBOROCK_S4_MAX,
7778
ROBOROCK_S7,
7879
ROBOROCK_P10,
80+
ROBOROCK_Q10,
7981
ROCKROBO_G10_SG,
8082
]
8183

roborock/data/b01_q10/b01_q10_code_mappings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ class B01_Q10_DP(RoborockModeEnum):
121121
class YXFanLevel(RoborockModeEnum):
122122
UNKNOWN = "unknown", -1
123123
CLOSE = "close", 0
124-
QUITE = "quite", 1
124+
QUIET = "quiet", 1
125125
NORMAL = "normal", 2
126126
STRONG = "strong", 3
127127
MAX = "max", 4

roborock/devices/traits/b01/q10/vacuum.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
"""Traits for Q10 B01 devices."""
22

3-
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
3+
from roborock.data.b01_q10.b01_q10_code_mappings import (
4+
B01_Q10_DP,
5+
YXCleanType,
6+
YXFanLevel,
7+
)
48

59
from .command import CommandTrait
610

@@ -54,3 +58,24 @@ async def return_to_dock(self) -> None:
5458
command=B01_Q10_DP.START_DOCK_TASK,
5559
params={},
5660
)
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+
)

tests/devices/traits/b01/q10/test_vacuum.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import pytest
66

7+
from roborock.data.b01_q10.b01_q10_code_mappings import YXCleanType, YXFanLevel
78
from roborock.devices.traits.b01.q10 import Q10PropertiesApi
89
from roborock.devices.traits.b01.q10.vacuum import VacuumTrait
910
from tests.fixtures.channel_fixtures import FakeChannel
@@ -32,6 +33,9 @@ def vacuumm_fixture(q10_api: Q10PropertiesApi) -> VacuumTrait:
3233
(lambda x: x.resume_clean(), {"205": {}}),
3334
(lambda x: x.stop_clean(), {"206": {}}),
3435
(lambda x: x.return_to_dock(), {"203": {}}),
36+
(lambda x: x.empty_dustbin(), {"203": 2}),
37+
(lambda x: x.set_clean_mode(YXCleanType.BOTH_WORK), {"137": 1}),
38+
(lambda x: x.set_fan_level(YXFanLevel.NORMAL), {"123": 2}),
3539
],
3640
)
3741
async def test_vacuum_commands(

0 commit comments

Comments
 (0)