Skip to content

Commit d96c4e4

Browse files
committed
feat: add b01 Q7 support
1 parent f13e87a commit d96c4e4

File tree

5 files changed

+129
-74
lines changed

5 files changed

+129
-74
lines changed

roborock/data/b01_q7/b01_q7_containers.py

Lines changed: 59 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -69,62 +69,62 @@ class B01Props(RoborockBase):
6969
This dataclass is generated based on the device's status JSON object.
7070
"""
7171

72-
status: WorkStatusMapping
73-
fault: B01Fault
74-
wind: SCWindMapping
75-
water: int
76-
mode: int
77-
quantity: int
78-
alarm: int
79-
volume: int
80-
hypa: int
81-
main_brush: int
82-
side_brush: int
83-
mop_life: int
84-
main_sensor: int
85-
net_status: NetStatus
86-
repeat_state: int
87-
tank_state: int
88-
sweep_type: int
89-
clean_path_preference: int
90-
cloth_state: int
91-
time_zone: int
92-
time_zone_info: str
93-
language: int
94-
cleaning_time: int
95-
real_clean_time: int
96-
cleaning_area: int
97-
custom_type: int
98-
sound: int
99-
work_mode: WorkModeMapping
100-
station_act: int
101-
charge_state: int
102-
current_map_id: int
103-
map_num: int
104-
dust_action: int
105-
quiet_is_open: int
106-
quiet_begin_time: int
107-
quiet_end_time: int
108-
clean_finish: int
109-
voice_type: int
110-
voice_type_version: int
111-
order_total: OrderTotal
112-
build_map: int
113-
privacy: Privacy
114-
dust_auto_state: int
115-
dust_frequency: int
116-
child_lock: int
117-
multi_floor: int
118-
map_save: int
119-
light_mode: int
120-
green_laser: int
121-
dust_bag_used: int
122-
order_save_mode: int
123-
manufacturer: str
124-
back_to_wash: int
125-
charge_station_type: int
126-
pv_cut_charge: int
127-
pv_charging: PvCharging
128-
serial_number: str
129-
recommend: Recommend
130-
add_sweep_status: int
72+
status: WorkStatusMapping | None = None
73+
fault: B01Fault | None = None
74+
wind: SCWindMapping | None = None
75+
water: int | None = None
76+
mode: int | None = None
77+
quantity: int | None = None
78+
alarm: int | None = None
79+
volume: int | None = None
80+
hypa: int | None = None
81+
main_brush: int | None = None
82+
side_brush: int | None = None
83+
mop_life: int | None = None
84+
main_sensor: int | None = None
85+
net_status: NetStatus | None = None
86+
repeat_state: int | None = None
87+
tank_state: int | None = None
88+
sweep_type: int | None = None
89+
clean_path_preference: int | None = None
90+
cloth_state: int | None = None
91+
time_zone: int | None = None
92+
time_zone_info: str | None = None
93+
language: int | None = None
94+
cleaning_time: int | None = None
95+
real_clean_time: int | None = None
96+
cleaning_area: int | None = None
97+
custom_type: int | None = None
98+
sound: int | None = None
99+
work_mode: WorkModeMapping | None = None
100+
station_act: int | None = None
101+
charge_state: int | None = None
102+
current_map_id: int | None = None
103+
map_num: int | None = None
104+
dust_action: int | None = None
105+
quiet_is_open: int | None = None
106+
quiet_begin_time: int | None = None
107+
quiet_end_time: int | None = None
108+
clean_finish: int | None = None
109+
voice_type: int | None = None
110+
voice_type_version: int | None = None
111+
order_total: OrderTotal | None = None
112+
build_map: int | None = None
113+
privacy: Privacy | None = None
114+
dust_auto_state: int | None = None
115+
dust_frequency: int | None = None
116+
child_lock: int | None = None
117+
multi_floor: int | None = None
118+
map_save: int | None = None
119+
light_mode: int | None = None
120+
green_laser: int | None = None
121+
dust_bag_used: int | None = None
122+
order_save_mode: int | None = None
123+
manufacturer: str | None = None
124+
back_to_wash: int | None = None
125+
charge_station_type: int | None = None
126+
pv_cut_charge: int | None = None
127+
pv_charging: PvCharging | None = None
128+
serial_number: str | None = None
129+
recommend: Recommend | None = None
130+
add_sweep_status: int | None = None

roborock/devices/b01_channel.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,76 @@
22

33
from __future__ import annotations
44

5+
import asyncio
6+
import json
57
import logging
8+
from typing import Any
69

10+
from roborock.exceptions import RoborockException
711
from roborock.protocols.b01_protocol import (
812
CommandType,
913
ParamsType,
14+
decode_rpc_response,
1015
encode_mqtt_payload,
1116
)
17+
from roborock.roborock_message import RoborockMessage
18+
from roborock.util import get_next_int
1219

1320
from .mqtt_channel import MqttChannel
1421

1522
_LOGGER = logging.getLogger(__name__)
23+
_TIMEOUT = 10.0
1624

1725

1826
async def send_decoded_command(
1927
mqtt_channel: MqttChannel,
2028
dps: int,
2129
command: CommandType,
2230
params: ParamsType,
23-
) -> None:
31+
) -> dict[str, Any]:
2432
"""Send a command on the MQTT channel and get a decoded response."""
2533
_LOGGER.debug("Sending MQTT command: %s", params)
26-
roborock_message = encode_mqtt_payload(dps, command, params)
27-
await mqtt_channel.publish(roborock_message)
34+
msg_id = get_next_int(100000000000, 999999999999)
35+
roborock_message = encode_mqtt_payload(dps, command, params, msg_id)
36+
finished = asyncio.Event()
37+
result: dict[str, Any] = {}
38+
39+
def find_response(response_message: RoborockMessage) -> None:
40+
"""Handle incoming messages and resolve the future."""
41+
try:
42+
decoded_dps = decode_rpc_response(response_message)
43+
except RoborockException as ex:
44+
_LOGGER.info("Failed to decode b01 message: %s: %s", response_message, ex)
45+
return
46+
47+
for _, dps_value in decoded_dps.items():
48+
# valid responses are JSON strings wrapped in the dps value
49+
if isinstance(dps_value, str):
50+
try:
51+
inner = json.loads(dps_value)
52+
except (json.JSONDecodeError, TypeError):
53+
_LOGGER.debug("Received unexpected response: %s", dps_value)
54+
continue
55+
56+
if isinstance(inner, dict) and inner.get("msgId") == msg_id:
57+
_LOGGER.debug("Received query response: %s", inner)
58+
data = inner.get("data")
59+
if isinstance(data, dict):
60+
result.update(data)
61+
finished.set()
62+
else:
63+
_LOGGER.debug("Received unexpected response: %s", dps_value)
64+
65+
unsub = await mqtt_channel.subscribe(find_response)
66+
67+
_LOGGER.debug("Sending MQTT message: %s", roborock_message)
68+
try:
69+
await mqtt_channel.publish(roborock_message)
70+
try:
71+
await asyncio.wait_for(finished.wait(), timeout=_TIMEOUT)
72+
except TimeoutError as ex:
73+
raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex
74+
finally:
75+
unsub()
76+
77+
return result

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Traits for Q7 B01 devices.
22
Potentially other devices may fall into this category in the future."""
33

4+
from roborock import B01Props
45
from roborock.devices.b01_channel import send_decoded_command
56
from roborock.devices.mqtt_channel import MqttChannel
67
from roborock.devices.traits import Trait
@@ -13,17 +14,18 @@
1314

1415

1516
class Q7PropertiesApi(Trait):
16-
"""API for interacting with Q7 B01 devices."""
17+
"""API for interacting with B01 devices."""
1718

1819
def __init__(self, channel: MqttChannel) -> None:
1920
"""Initialize the B01Props API."""
2021
self._channel = channel
2122

22-
async def query_values(self, props: list[RoborockB01Props]) -> None:
23-
"""Query the device for the values of the given Q7 properties."""
24-
await send_decoded_command(
23+
async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
24+
"""Query the device for the values of the given Dyad protocols."""
25+
result = await send_decoded_command(
2526
self._channel, dps=10000, command=RoborockB01Q7Methods.GET_PROP, params={"property": props}
2627
)
28+
return B01Props.from_dict(result)
2729

2830

2931
def create(channel: MqttChannel) -> Q7PropertiesApi:

roborock/protocol.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -276,12 +276,11 @@ def _encode(self, obj, context, _):
276276
if context.version == b"A01":
277277
iv = md5hex(format(context.random, "08x") + A01_HASH)[8:24]
278278
decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
279-
f = decipher.encrypt(obj)
280-
return f
279+
return decipher.encrypt(pad(obj, AES.block_size))
281280
elif context.version == b"B01":
282281
iv = md5hex(f"{context.random:08x}" + B01_HASH)[9:25]
283282
decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
284-
return decipher.encrypt(obj)
283+
return decipher.encrypt(pad(obj, AES.block_size))
285284
elif context.version == b"L01":
286285
return Utils.encrypt_gcm_l01(
287286
plaintext=obj,
@@ -301,12 +300,11 @@ def _decode(self, obj, context, _):
301300
if context.version == b"A01":
302301
iv = md5hex(format(context.random, "08x") + A01_HASH)[8:24]
303302
decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
304-
f = decipher.decrypt(obj)
305-
return f
303+
return unpad(decipher.decrypt(obj), AES.block_size)
306304
elif context.version == b"B01":
307305
iv = md5hex(f"{context.random:08x}" + B01_HASH)[9:25]
308306
decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
309-
return decipher.decrypt(obj)
307+
return unpad(decipher.decrypt(obj), AES.block_size)
310308
elif context.version == b"L01":
311309
return Utils.decrypt_gcm_l01(
312310
payload=obj,
@@ -350,6 +348,11 @@ def _parse(self, stream, context, path):
350348
# Read remaining data to find a valid header
351349
data = stream.read()
352350

351+
if not data:
352+
# EOF reached, let the parser fail naturally without logging
353+
stream_seek(stream, current_pos, 0, path)
354+
return super()._parse(stream, context, path)
355+
353356
start_index = -1
354357
# Find the earliest occurrence of any valid version in a single pass
355358
for i in range(len(data) - 2):

roborock/protocols/b01_protocol.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@
2222
ParamsType = list | dict | int | None
2323

2424

25-
def encode_mqtt_payload(dps: int, command: CommandType, params: ParamsType) -> RoborockMessage:
25+
def encode_mqtt_payload(dps: int, command: CommandType, params: ParamsType, msg_id: int) -> RoborockMessage:
2626
"""Encode payload for B01 commands over MQTT."""
2727
dps_data = {
2828
"dps": {
2929
dps: {
3030
"method": str(command),
31-
"msgId": str(get_next_int(100000000000, 999999999999)),
31+
"msgId": msg_id or str(get_next_int(100000000000, 999999999999)),
3232
"params": params or [],
3333
}
3434
}

0 commit comments

Comments
 (0)