Skip to content

Commit b3664bc

Browse files
Lash-LallenporterCopilot
authored
feat: add b01 Q7 basic getter support (#662)
* feat: add b01 Q7 support * chore: small tweaks * fix: test * chore: tests * chore: fix test naming * chore: update snapshot * chore: update roborock/devices/b01_channel.py Co-authored-by: Allen Porter <allen.porter@gmail.com> * chore: apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Allen Porter <allen.porter@gmail.com> * chore: address some comments * chore: rename --------- Co-authored-by: Allen Porter <allen.porter@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 2b21387 commit b3664bc

File tree

12 files changed

+381
-76
lines changed

12 files changed

+381
-76
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 = str(get_next_int(100000000000, 999999999999))
35+
roborock_message = encode_mqtt_payload(dps, command, params, msg_id)
36+
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
37+
38+
def find_response(response_message: RoborockMessage) -> None:
39+
"""Handle incoming messages and resolve the future."""
40+
try:
41+
decoded_dps = decode_rpc_response(response_message)
42+
except RoborockException as ex:
43+
_LOGGER.info("Failed to decode b01 message: %s: %s", response_message, ex)
44+
return
45+
46+
for dps_value in decoded_dps.values():
47+
# valid responses are JSON strings wrapped in the dps value
48+
if not isinstance(dps_value, str):
49+
_LOGGER.debug("Received unexpected response: %s", dps_value)
50+
continue
51+
52+
try:
53+
inner = json.loads(dps_value)
54+
except (json.JSONDecodeError, TypeError):
55+
_LOGGER.debug("Received unexpected response: %s", dps_value)
56+
continue
57+
58+
if isinstance(inner, dict) and inner.get("msgId") == msg_id:
59+
_LOGGER.debug("Received query response: %s", inner)
60+
data = inner.get("data")
61+
if not future.done():
62+
if isinstance(data, dict):
63+
future.set_result(data)
64+
else:
65+
future.set_exception(RoborockException(f"Unexpected data type for response: {data}"))
66+
67+
unsub = await mqtt_channel.subscribe(find_response)
68+
69+
_LOGGER.debug("Sending MQTT message: %s", roborock_message)
70+
try:
71+
await mqtt_channel.publish(roborock_message)
72+
try:
73+
return await asyncio.wait_for(future, timeout=_TIMEOUT)
74+
except TimeoutError as ex:
75+
raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex
76+
finally:
77+
unsub()

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

Lines changed: 5 additions & 3 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+
async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
2324
"""Query the device for the values of the given Q7 properties."""
24-
await send_decoded_command(
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: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
RoborockMessage,
1414
RoborockMessageProtocol,
1515
)
16-
from roborock.util import get_next_int
1716

1817
_LOGGER = logging.getLogger(__name__)
1918

@@ -22,13 +21,13 @@
2221
ParamsType = list | dict | int | None
2322

2423

25-
def encode_mqtt_payload(dps: int, command: CommandType, params: ParamsType) -> RoborockMessage:
24+
def encode_mqtt_payload(dps: int, command: CommandType, params: ParamsType, msg_id: str) -> RoborockMessage:
2625
"""Encode payload for B01 commands over MQTT."""
2726
dps_data = {
2827
"dps": {
2928
dps: {
3029
"method": str(command),
31-
"msgId": str(get_next_int(100000000000, 999999999999)),
30+
"msgId": msg_id,
3231
"params": params or [],
3332
}
3433
}
@@ -47,8 +46,9 @@ def decode_rpc_response(message: RoborockMessage) -> dict[int, Any]:
4746
raise RoborockException("Invalid B01 message format: missing payload")
4847
try:
4948
unpadded = unpad(message.payload, AES.block_size)
50-
except ValueError as err:
51-
raise RoborockException(f"Unable to unpad B01 payload: {err}")
49+
except ValueError:
50+
# It would be better to fail down the line.
51+
unpadded = message.payload
5252

5353
try:
5454
payload = json.loads(unpadded.decode())

tests/devices/traits/a01/__init__.py

Whitespace-only changes.

tests/devices/traits/b01/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)