Skip to content

Commit 7bbf593

Browse files
committed
feat: implement Q10VacuumSimulator and export it
1 parent c90b396 commit 7bbf593

4 files changed

Lines changed: 389 additions & 8 deletions

File tree

roborock/testing/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ async def test_start_vacuum_service():
6363
```
6464
"""
6565

66+
from roborock.testing.b01_q10_simulator import (
67+
DEFAULT_Q10_STATUS,
68+
Q10VacuumSimulator,
69+
)
6670
from roborock.testing.channel import FakeChannel
6771
from roborock.testing.cloud import FakeRoborockCloud, FakeWebApiClient
6872
from roborock.testing.simulator import (
@@ -92,10 +96,12 @@ async def test_start_vacuum_service():
9296
"DEFAULT_LOCAL_KEY",
9397
"DEFAULT_NETWORK_INFO",
9498
"DEFAULT_PRODUCT_ID",
99+
"DEFAULT_Q10_STATUS",
95100
"DEFAULT_STATUS",
96101
"FakeChannel",
97102
"FakeRoborockCloud",
98103
"FakeWebApiClient",
104+
"Q10VacuumSimulator",
99105
"RoborockDeviceSimulator",
100106
"V1VacuumSimulator",
101107
]
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""Stateful simulator for Roborock Q10 (B01) devices."""
2+
3+
import copy
4+
import json
5+
import logging
6+
from typing import Any
7+
8+
from roborock.data import HomeDataDevice, HomeDataProduct, RoborockCategory
9+
from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
10+
from roborock.testing.simulator import RoborockDeviceSimulator
11+
12+
_LOGGER = logging.getLogger(__name__)
13+
14+
B01_VERSION = b"B01"
15+
DEFAULT_PRODUCT_ID = "product-id-q10"
16+
17+
DEFAULT_Q10_STATUS = {
18+
101: {
19+
104: 0,
20+
105: False,
21+
109: "us",
22+
207: 0,
23+
25: 1,
24+
26: 74,
25+
29: 0,
26+
30: 0,
27+
31: 0,
28+
37: 1,
29+
40: 1,
30+
45: 0,
31+
47: 0,
32+
50: 0,
33+
51: True,
34+
53: False,
35+
6: 0,
36+
60: 1,
37+
67: 0,
38+
7: 0,
39+
76: 0,
40+
78: 0,
41+
79: {"timeZoneCity": "America/Los_Angeles", "timeZoneSec": -28800},
42+
80: 0,
43+
81: {"ipAdress": "1.1.1.2", "mac": "99:AA:88:BB:77:CC", "signal": -50, "wifiName": "wifi-network-name"},
44+
83: 1,
45+
86: 1,
46+
87: 100,
47+
88: 0,
48+
90: 0,
49+
92: {"disturb_dust_enable": 1, "disturb_light": 1, "disturb_resume_clean": 1, "disturb_voice": 1},
50+
93: 1,
51+
96: 0,
52+
},
53+
121: 8,
54+
122: 100,
55+
123: 2,
56+
124: 1,
57+
125: 0,
58+
126: 0,
59+
127: 0,
60+
136: 1,
61+
137: 1,
62+
138: 0,
63+
139: 5,
64+
}
65+
66+
67+
class Q10VacuumSimulator(RoborockDeviceSimulator):
68+
"""Stateful firmware simulator for Roborock Q10 (B01 ss07) devices."""
69+
70+
def __init__(
71+
self,
72+
duid: str,
73+
status: dict[int, Any] | None = None,
74+
device_info: HomeDataDevice | None = None,
75+
product: HomeDataProduct | None = None,
76+
):
77+
product = product or HomeDataProduct(
78+
id=DEFAULT_PRODUCT_ID,
79+
name="Roborock Q10",
80+
model="roborock.vacuum.ss07",
81+
category=RoborockCategory.VACUUM,
82+
)
83+
device_info = device_info or HomeDataDevice(
84+
duid=duid,
85+
name="Roborock Q10",
86+
local_key="fake_localkey_16bytes",
87+
product_id=product.id,
88+
sn="fake_serial_number",
89+
pv="B01",
90+
)
91+
super().__init__(duid, device_info, product, has_local_channel=False)
92+
self.status = copy.deepcopy(status or DEFAULT_Q10_STATUS)
93+
94+
async def _handle_publish(self, message: RoborockMessage, channel: Any) -> None:
95+
"""Process incoming Q10 RPC command payload."""
96+
if not message.payload:
97+
return
98+
99+
try:
100+
payload = json.loads(message.payload.decode())
101+
except (json.JSONDecodeError, UnicodeDecodeError) as ex:
102+
_LOGGER.warning("Simulator failed to parse incoming B01 payload: %s", ex)
103+
return
104+
105+
datapoints = payload.get("dps", {})
106+
if not isinstance(datapoints, dict):
107+
return
108+
109+
updated_dps: dict[int, Any] = {}
110+
111+
for code_str, params in datapoints.items():
112+
try:
113+
code = int(code_str)
114+
except ValueError:
115+
continue
116+
117+
# Command: REQUEST_DPS (102) -> trigger status push
118+
if code == 102:
119+
# REQUEST_DPS triggers a dump of all status properties
120+
# (both root properties and nested properties under 101)
121+
self.trigger_push_update()
122+
continue
123+
124+
# Command: START_CLEAN (201)
125+
elif code == 201:
126+
# 1 = sweep/mop, cmd inside dict = segment clean
127+
self.status[121] = 5 # YXDeviceState.CLEANING
128+
if isinstance(params, dict) and "cmd" in params:
129+
self.status[138] = params.get("cmd", 0) # clean_task_type
130+
else:
131+
self.status[138] = 1 # CLEANING
132+
updated_dps[121] = self.status[121]
133+
updated_dps[138] = self.status[138]
134+
135+
# Command: START_BACK (202)
136+
elif code == 202:
137+
# 5 = returning to charge
138+
self.status[121] = 6 # YXDeviceState.RETURNING_HOME
139+
self.status[139] = 5 # BACK_CHARGING
140+
updated_dps[121] = self.status[121]
141+
updated_dps[139] = self.status[139]
142+
143+
# Command: START_DOCK_TASK (203)
144+
elif code == 203:
145+
# Empty dustbin
146+
self.status[121] = 22 # EMPTYING_THE_BIN
147+
updated_dps[121] = self.status[121]
148+
149+
# Command: PAUSE (204)
150+
elif code == 204:
151+
self.status[121] = 10 # YXDeviceState.PAUSED
152+
updated_dps[121] = self.status[121]
153+
154+
# Command: RESUME (205)
155+
elif code == 205:
156+
self.status[121] = 5 # YXDeviceState.CLEANING
157+
updated_dps[121] = self.status[121]
158+
159+
# Command: STOP (206)
160+
elif code == 206:
161+
self.status[121] = 3 # YXDeviceState.IDLE
162+
self.status[138] = 0 # YXDeviceCleanTask.UNKNOWN/IDLE
163+
updated_dps[121] = self.status[121]
164+
updated_dps[138] = self.status[138]
165+
166+
# Command: FAN_LEVEL (123)
167+
elif code == 123:
168+
self.status[123] = params
169+
updated_dps[123] = params
170+
171+
# Command: WATER_LEVEL (124)
172+
elif code == 124:
173+
self.status[124] = params
174+
updated_dps[124] = params
175+
176+
# Command: CLEAN_MODE (137)
177+
elif code == 137:
178+
self.status[137] = params
179+
updated_dps[137] = params
180+
181+
# Custom settings properties routed to sub-traits (DND, child lock, volume, dust collections)
182+
elif code in (26, 47, 25, 37, 50):
183+
# These live in the nested dpCommon (101) dict
184+
common_dict = self.status[101]
185+
if isinstance(common_dict, dict):
186+
common_dict[code] = params
187+
# We return them nested under 101
188+
if 101 not in updated_dps:
189+
updated_dps[101] = {}
190+
updated_dps[101][code] = params
191+
192+
if updated_dps:
193+
# Send the changed datapoints back to the client
194+
self.push_dps(updated_dps)
195+
196+
def push_dps(self, dps_updates: dict[int, Any]) -> None:
197+
"""Push a set of status datapoint changes to the client."""
198+
payload = {
199+
"dps": {
200+
str(k): ({str(sub_k): sub_v for sub_k, sub_v in v.items()} if isinstance(v, dict) and k == 101 else v)
201+
for k, v in dps_updates.items()
202+
}
203+
}
204+
message = RoborockMessage(
205+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
206+
version=B01_VERSION,
207+
payload=json.dumps(payload).encode("utf-8"),
208+
)
209+
self.mqtt_channel.notify_subscribers(message)
210+
211+
def trigger_push_update(self) -> None:
212+
"""Send the full status dump to the client."""
213+
self.push_dps(self.status)

roborock/testing/cloud.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,6 @@ def patch_device_manager(self):
242242

243243
# Wrapper function for create_v1_channel
244244
def mock_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache):
245-
if device.pv in ("A01", "B01"):
246-
raise NotImplementedError(
247-
f"Simulating protocol {device.pv} is not yet supported. "
248-
"TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices."
249-
)
250245
server = self.simulated_devices.get(device.duid)
251246
if server is not None:
252247
if not isinstance(server, V1VacuumSimulator):
@@ -255,18 +250,23 @@ def mock_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_
255250
f"simulator, but create_v1_channel requires a V1VacuumSimulator."
256251
)
257252
return server.v1_channel
253+
if device.pv in ("A01", "B01"):
254+
raise NotImplementedError(
255+
f"Simulating protocol {device.pv} is not yet supported. "
256+
"TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices."
257+
)
258258
return original_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache)
259259

260260
# Wrapper function for create_mqtt_channel
261261
def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device):
262+
server = self.simulated_devices.get(device.duid)
263+
if server:
264+
return server.mqtt_channel
262265
if device.pv in ("A01", "B01"):
263266
raise NotImplementedError(
264267
f"Simulating protocol {device.pv} is not yet supported. "
265268
"TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices."
266269
)
267-
server = self.simulated_devices.get(device.duid)
268-
if server:
269-
return server.mqtt_channel
270270
return original_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
271271

272272
# Route Web requests using the dynamic FakeWebApiClient

0 commit comments

Comments
 (0)