Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion roborock/data/b01_q7/b01_q7_code_mappings.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ class SCWindMapping(RoborockModeEnum):
STANDARD = ("balanced", 1)
STRONG = ("turbo", 2)
SUPER_STRONG = ("max", 3)
MAX = ("max_plus", 4)


class WaterLevelMapping(RoborockModeEnum):
Expand All @@ -50,6 +49,14 @@ class CleanRepeatMapping(RoborockModeEnum):
TWICE = ("twice", 1)


class SCDeviceCleanParam(RoborockModeEnum):
"""Maps the control values for cleaning tasks."""

STOP = ("stop", 0)
START = ("start", 1)
PAUSE = ("pause", 2)


class WorkModeMapping(RoborockModeEnum):
"""Maps the detailed work modes of the robot."""

Expand Down
29 changes: 18 additions & 11 deletions roborock/devices/b01_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,15 @@ async def send_decoded_command(
dps: int,
command: CommandType,
params: ParamsType,
) -> dict[str, Any]:
"""Send a command on the MQTT channel and get a decoded response."""
) -> Any:
"""Send a command on the MQTT channel and get a decoded response.

Note: B01 "set" commands may return a scalar (e.g. 0/"ok") rather than a dict.
Comment thread
Lash-L marked this conversation as resolved.
Outdated
"""
_LOGGER.debug("Sending MQTT command: %s", params)
msg_id = str(get_next_int(100000000000, 999999999999))
roborock_message = encode_mqtt_payload(dps, command, params, msg_id)
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()

def find_response(response_message: RoborockMessage) -> None:
"""Handle incoming messages and resolve the future."""
Expand All @@ -58,20 +61,24 @@ def find_response(response_message: RoborockMessage) -> None:
if isinstance(inner, dict) and inner.get("msgId") == msg_id:
_LOGGER.debug("Received query response: %s", inner)
data = inner.get("data")
# All get commands should be dicts
if command.endswith(".get") and not isinstance(data, dict):
if not future.done():
future.set_exception(RoborockException("Unexpected data type for response"))
return
Comment on lines +84 to +93
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation logic only checks if the command ends with ".get" to determine if the response should be a dict. However, this string-based check may not cover all query commands. Consider also checking for commands that end with ".post" or other query-like patterns if they are expected to return dictionaries. Alternatively, consider using a more explicit enumeration of command types (e.g., GET vs SET) to avoid potential false positives or negatives with string matching.

Copilot uses AI. Check for mistakes.
if not future.done():
if isinstance(data, dict):
future.set_result(data)
else:
future.set_exception(RoborockException(f"Unexpected data type for response: {data}"))
future.set_result(data)

unsub = await mqtt_channel.subscribe(find_response)

_LOGGER.debug("Sending MQTT message: %s", roborock_message)
try:
await mqtt_channel.publish(roborock_message)
try:
return await asyncio.wait_for(future, timeout=_TIMEOUT)
except TimeoutError as ex:
raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex
return await asyncio.wait_for(future, timeout=_TIMEOUT)
except TimeoutError as ex:
raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex
except Exception as ex:
Comment thread
Lash-L marked this conversation as resolved.
_LOGGER.exception("Error sending decoded command: %s", ex)
Comment thread
Lash-L marked this conversation as resolved.
Outdated
raise
finally:
unsub()
85 changes: 83 additions & 2 deletions roborock/devices/traits/b01/q7/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
"""Traits for Q7 B01 devices.
Potentially other devices may fall into this category in the future."""

from typing import Any

from roborock import B01Props
from roborock.data.b01_q7.b01_q7_code_mappings import (
CleanTaskTypeMapping,
SCDeviceCleanParam,
SCWindMapping,
WaterLevelMapping,
)
from roborock.devices.b01_channel import send_decoded_command
from roborock.devices.mqtt_channel import MqttChannel
from roborock.devices.traits import Trait
Expand All @@ -20,13 +28,86 @@ def __init__(self, channel: MqttChannel) -> None:
"""Initialize the B01Props API."""
self._channel = channel

async def send(self, command: RoborockB01Q7Methods, params: dict) -> Any:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add typing

Suggested change
async def send(self, command: RoborockB01Q7Methods, params: dict) -> Any:
async def send(self, command: RoborockB01Q7Methods, params: dict[str, Any]) -> Any:

Comment thread
Lash-L marked this conversation as resolved.
Outdated
return await send_decoded_command(
self._channel,
dps=10000,
command=command,
params=params,
)

async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
"""Query the device for the values of the given Q7 properties."""
result = await send_decoded_command(
self._channel, dps=10000, command=RoborockB01Q7Methods.GET_PROP, params={"property": props}
result = await self.send(
RoborockB01Q7Methods.GET_PROP,
{"property": props},
)
if not isinstance(result, dict):
raise TypeError(f"Unexpected response type for GET_PROP: {type(result).__name__}: {result!r}")
return B01Props.from_dict(result)

async def set_prop(self, prop: RoborockB01Props, value: Any) -> Any:
Comment thread
Lash-L marked this conversation as resolved.
Outdated
"""Set a property on the device."""
await self.send(
command=RoborockB01Q7Methods.SET_PROP,
params={prop: value},
)

async def set_fan_speed(self, fan_speed: SCWindMapping) -> Any:
Comment thread
Lash-L marked this conversation as resolved.
Outdated
"""Set the fan speed (wind)."""
return await self.set_prop(RoborockB01Props.WIND, fan_speed.code)

async def set_water_level(self, water_level: WaterLevelMapping) -> Any:
"""Set the water level (water)."""
return await self.set_prop(RoborockB01Props.WATER, water_level.code)

async def start_clean(self) -> Any:
"""Start cleaning."""
return await self.send(
command=RoborockB01Q7Methods.SET_ROOM_CLEAN,
params={
"clean_type": CleanTaskTypeMapping.ALL.code,
"ctrl_value": SCDeviceCleanParam.START.code,
"room_ids": [],
},
)

async def pause_clean(self) -> Any:
"""Pause cleaning."""
return await self.send(
command=RoborockB01Q7Methods.SET_ROOM_CLEAN,
params={
"clean_type": CleanTaskTypeMapping.ALL.code,
"ctrl_value": SCDeviceCleanParam.PAUSE.code,
"room_ids": [],
},
)

async def stop_clean(self) -> Any:
"""Stop cleaning."""
return await self.send(
command=RoborockB01Q7Methods.SET_ROOM_CLEAN,
params={
"clean_type": CleanTaskTypeMapping.ALL.code,
"ctrl_value": SCDeviceCleanParam.STOP.code,
"room_ids": [],
},
)

async def return_to_dock(self) -> Any:
"""Return to dock."""
return await self.send(
command=RoborockB01Q7Methods.START_RECHARGE,
params={},
)

async def find_me(self) -> Any:
"""Locate the robot."""
return await self.send(
command=RoborockB01Q7Methods.FIND_DEVICE,
params={},
)


def create(channel: MqttChannel) -> Q7PropertiesApi:
"""Create traits for B01 devices."""
Expand Down
5 changes: 4 additions & 1 deletion roborock/protocols/b01_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ def encode_mqtt_payload(dps: int, command: CommandType, params: ParamsType, msg_
dps: {
"method": str(command),
"msgId": msg_id,
"params": params or [],
# Important: some B01 methods use an empty object `{}` (not `[]`) for
# "no params", and some setters legitimately send `0` which is falsy.
# Only default to `[]` when params is actually None.
"params": params if params is not None else [],
}
}
}
Expand Down
55 changes: 54 additions & 1 deletion roborock/roborock_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,14 +280,67 @@ class RoborockCommand(str, Enum):
class RoborockB01Q7Methods(StrEnum):
"""Methods used by the Roborock Q7 model."""

# NOTE: In the Q7 Hermes dump these appear as suffixes and are also used
# with an "event." prefix at runtime (see `hermes/.../module_524.js`).
ADD_CLEAN_FAILED_POST = "add_clean_failed.post"
EVENT_ADD_CLEAN_FAILED_POST = "event.add_clean_failed.post"
CLEAN_FINISH_POST = "clean_finish.post"
EVENT_CLEAN_FINISH_POST = "event.clean_finish.post"
EVENT_BUILD_MAP_FINISH_POST = "event.BuildMapFinish.post"
EVENT_MAP_CHANGE_POST = "event.map_change.post"
EVENT_WORK_APPOINT_CLEAN_FAILED_POST = "event.work_appoint_clean_failed.post"
START_CLEAN_POST = "startClean.post"
ADD_ORDER = "service.add_order"
ADD_SWEEP_CLEAN = "service.add_sweep_clean"
ARRANGE_ROOM = "service.arrange_room"
DEL_MAP = "service.del_map"
DEL_ORDER = "service.del_order"
DEL_ORDERS = "service.del_orders"
DELETE_RECORD_BY_URL = "service.delete_record_by_url"
DOWNLOAD_VOICE_TYPE = "service.download_voice_type"
ERASE_PREFERENCE = "service.erase_preference"
FIND_DEVICE = "service.find_device"
GET_ROOM_ORDER = "service.get_room_order"
GET_VOICE_DOWNLOAD = "service.get_voice_download"
HELLO_WIKKA = "service.hello_wikka"
RENAME_MAP = "service.rename_map"
RENAME_ROOM = "service.rename_room"
RENAME_ROOMS = "service.rename_rooms"
REPLACE_MAP = "service.replace_map"
RESET_CONSUMABLE = "service.reset_consumable"
SAVE_CARPET = "service.save_carpet"
SAVE_RECOMMEND_FB = "service.save_recommend_fb"
SAVE_SILL = "service.save_sill"
SET_AREA_START = "service.set_area_start"
SET_AREAS_START = "service.set_areas_start"
SET_CUR_MAP = "service.set_cur_map"
SET_DIRECTION = "service.set_direction"
SET_GLOBAL_SORT = "service.set_global_sort"
SET_MAP_HIDE = "service.set_map_hide"
SET_MULTI_ROOM_MATERIAL = "service.set_multi_room_material"
SET_POINT_CLEAN = "service.set_point_clean"
SET_PREFERENCE = "service.set_preference"
SET_PREFERENCE_TYPE = "service.set_preference_type"
SET_QUIET_TIME = "service.set_quiet_time"
SET_ROOM_CLEAN = "service.set_room_clean"
SET_ROOM_ORDER = "service.set_room_order"
SET_VIRTUAL_WALL = "service.set_virtual_wall"
SET_ZONE_CLEAN = "service.set_zone_clean"
SET_ZONE_POINTS = "service.set_zone_points"
SPLIT_ROOM = "service.split_room"
START_EXPLORE = "service.start_explore"
START_POINT_CLEAN = "service.start_point_clean"
START_RECHARGE = "service.start_recharge"
STOP_RECHARGE = "service.stop_recharge"
UPLOAD_BY_MAPID = "service.upload_by_mapid"
UPLOAD_RECORD_BY_URL = "service.upload_record_by_url"
GET_PROP = "prop.get"
GET_MAP_LIST = "service.get_map_list"
UPLOAD_BY_MAPTYPE = "service.upload_by_maptype"
SET_PROP = "prop.set"
GET_PREFERENCE = "service.get_preference"
GET_RECORD_LIST = "service.get_record_list"
GET_ORDER = "service.get_order"
EVENT_ORDER_LIST_POST = "event.order_list.post"
POST_PROP = "prop.post"


Expand Down
118 changes: 117 additions & 1 deletion tests/devices/traits/b01/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad

from roborock.data.b01_q7 import WorkStatusMapping
from roborock.data.b01_q7 import (
CleanTaskTypeMapping,
SCDeviceCleanParam,
SCWindMapping,
WaterLevelMapping,
WorkStatusMapping,
)
from roborock.devices.b01_channel import send_decoded_command
from roborock.devices.traits.b01.q7 import Q7PropertiesApi
from roborock.exceptions import RoborockException
Expand Down Expand Up @@ -164,3 +170,113 @@ async def test_send_decoded_command_non_dict_response(fake_channel: FakeChannel)

with pytest.raises(RoborockException, match="Unexpected data type for response"):
await send_decoded_command(fake_channel, 10000, "prop.get", []) # type: ignore[arg-type]


async def test_q7_api_set_fan_speed(q7_api: Q7PropertiesApi, fake_channel: FakeChannel):
"""Test setting fan speed."""
msg_id = "12345"
with patch("roborock.devices.b01_channel.get_next_int", return_value=int(msg_id)):
fake_channel.response_queue.append(build_b01_message({"result": "ok"}, msg_id=msg_id))
await q7_api.set_fan_speed(SCWindMapping.STRONG)

assert len(fake_channel.published_messages) == 1
message = fake_channel.published_messages[0]
payload_data = json.loads(unpad(message.payload, AES.block_size))
assert payload_data["dps"]["10000"]["method"] == "prop.set"
assert payload_data["dps"]["10000"]["params"] == {RoborockB01Props.WIND: SCWindMapping.STRONG.code}


async def test_q7_api_set_water_level(q7_api: Q7PropertiesApi, fake_channel: FakeChannel):
"""Test setting water level."""
msg_id = "12346"
with patch("roborock.devices.b01_channel.get_next_int", return_value=int(msg_id)):
fake_channel.response_queue.append(build_b01_message({"result": "ok"}, msg_id=msg_id))
await q7_api.set_water_level(WaterLevelMapping.HIGH)

assert len(fake_channel.published_messages) == 1
message = fake_channel.published_messages[0]
payload_data = json.loads(unpad(message.payload, AES.block_size))
assert payload_data["dps"]["10000"]["method"] == "prop.set"
assert payload_data["dps"]["10000"]["params"] == {RoborockB01Props.WATER: WaterLevelMapping.HIGH.code}


async def test_q7_api_start_clean(q7_api: Q7PropertiesApi, fake_channel: FakeChannel):
"""Test starting cleaning."""
msg_id = "12347"
with patch("roborock.devices.b01_channel.get_next_int", return_value=int(msg_id)):
fake_channel.response_queue.append(build_b01_message({"result": "ok"}, msg_id=msg_id))
await q7_api.start_clean()

assert len(fake_channel.published_messages) == 1
message = fake_channel.published_messages[0]
payload_data = json.loads(unpad(message.payload, AES.block_size))
assert payload_data["dps"]["10000"]["method"] == "service.set_room_clean"
assert payload_data["dps"]["10000"]["params"] == {
"clean_type": CleanTaskTypeMapping.ALL.code,
"ctrl_value": SCDeviceCleanParam.START.code,
"room_ids": [],
}


async def test_q7_api_pause_clean(q7_api: Q7PropertiesApi, fake_channel: FakeChannel):
"""Test pausing cleaning."""
msg_id = "12348"
with patch("roborock.devices.b01_channel.get_next_int", return_value=int(msg_id)):
fake_channel.response_queue.append(build_b01_message({"result": "ok"}, msg_id=msg_id))
await q7_api.pause_clean()

assert len(fake_channel.published_messages) == 1
message = fake_channel.published_messages[0]
payload_data = json.loads(unpad(message.payload, AES.block_size))
assert payload_data["dps"]["10000"]["method"] == "service.set_room_clean"
assert payload_data["dps"]["10000"]["params"] == {
"clean_type": CleanTaskTypeMapping.ALL.code,
"ctrl_value": SCDeviceCleanParam.PAUSE.code,
"room_ids": [],
}


async def test_q7_api_stop_clean(q7_api: Q7PropertiesApi, fake_channel: FakeChannel):
"""Test stopping cleaning."""
msg_id = "12349"
with patch("roborock.devices.b01_channel.get_next_int", return_value=int(msg_id)):
fake_channel.response_queue.append(build_b01_message({"result": "ok"}, msg_id=msg_id))
await q7_api.stop_clean()

assert len(fake_channel.published_messages) == 1
message = fake_channel.published_messages[0]
payload_data = json.loads(unpad(message.payload, AES.block_size))
assert payload_data["dps"]["10000"]["method"] == "service.set_room_clean"
assert payload_data["dps"]["10000"]["params"] == {
"clean_type": CleanTaskTypeMapping.ALL.code,
"ctrl_value": SCDeviceCleanParam.STOP.code,
"room_ids": [],
}


async def test_q7_api_return_to_dock(q7_api: Q7PropertiesApi, fake_channel: FakeChannel):
"""Test returning to dock."""
msg_id = "12350"
with patch("roborock.devices.b01_channel.get_next_int", return_value=int(msg_id)):
fake_channel.response_queue.append(build_b01_message({"result": "ok"}, msg_id=msg_id))
await q7_api.return_to_dock()

assert len(fake_channel.published_messages) == 1
message = fake_channel.published_messages[0]
payload_data = json.loads(unpad(message.payload, AES.block_size))
assert payload_data["dps"]["10000"]["method"] == "service.start_recharge"
assert payload_data["dps"]["10000"]["params"] == {}


async def test_q7_api_find_me(q7_api: Q7PropertiesApi, fake_channel: FakeChannel):
"""Test locating the device."""
msg_id = "12351"
with patch("roborock.devices.b01_channel.get_next_int", return_value=int(msg_id)):
fake_channel.response_queue.append(build_b01_message({"result": "ok"}, msg_id=msg_id))
await q7_api.find_me()

assert len(fake_channel.published_messages) == 1
message = fake_channel.published_messages[0]
payload_data = json.loads(unpad(message.payload, AES.block_size))
assert payload_data["dps"]["10000"]["method"] == "service.find_device"
assert payload_data["dps"]["10000"]["params"] == {}