Skip to content

Commit 1738ee5

Browse files
authored
Merge branch 'main' into repeat_and_route
2 parents 3f50fa3 + 601a402 commit 1738ee5

22 files changed

+662
-94
lines changed

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,32 @@
22

33
<!-- version list -->
44

5+
## v4.10.0 (2026-02-01)
6+
7+
### Features
8+
9+
- Add clean record for Q7 ([#745](https://github.com/Python-roborock/python-roborock/pull/745),
10+
[`329e52b`](https://github.com/Python-roborock/python-roborock/commit/329e52bc34b1a5de2685b94002deae025eb0bd1c))
11+
12+
13+
## v4.9.1 (2026-02-01)
14+
15+
### Bug Fixes
16+
17+
- Correctly handle unknown categories
18+
([#755](https://github.com/Python-roborock/python-roborock/pull/755),
19+
[`742a382`](https://github.com/Python-roborock/python-roborock/commit/742a38200e943a987285cc6979c7e7d5ca729117))
20+
21+
22+
## v4.9.0 (2026-02-01)
23+
24+
### Features
25+
26+
- Add VacuumTrait to q10 devices
27+
([#754](https://github.com/Python-roborock/python-roborock/pull/754),
28+
[`69b6e0f`](https://github.com/Python-roborock/python-roborock/commit/69b6e0f58ce470f59a3d57756e6b4f760f3fd5a0))
29+
30+
531
## v4.8.0 (2026-01-27)
632

733
### Features

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "python-roborock"
3-
version = "4.8.0"
3+
version = "4.10.0"
44
description = "A package to control Roborock vacuums."
55
authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}]
66
requires-python = ">=3.11, <4"

roborock/data/b01_q10/b01_q10_code_mappings.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class B01_Q10_DP(RoborockModeEnum):
3434
DUST_SETTING = ("dpDustSetting", 50)
3535
MAP_SAVE_SWITCH = ("dpMapSaveSwitch", 51)
3636
CLEAN_RECORD = ("dpCleanRecord", 52)
37-
RECEND_CLEAN_RECORD = ("dpRecendCleanRecord", 53)
37+
RECENT_CLEAN_RECORD = ("dpRecentCleanRecord", 53) # NOTE: typo "dpRecendCleanRecord" in source code
3838
RESTRICTED_ZONE = ("dpRestrictedZone", 54)
3939
RESTRICTED_ZONE_UP = ("dpRestrictedZoneUp", 55)
4040
VIRTUAL_WALL = ("dpVirtualWall", 56)
@@ -56,7 +56,7 @@ class B01_Q10_DP(RoborockModeEnum):
5656
ROOM_MERGE = ("dpRoomMerge", 72)
5757
ROOM_SPLIT = ("dpRoomSplit", 73)
5858
RESET_ROOM_NAME = ("dpResetRoomName", 74)
59-
REQUSET_NOT_DISTURB_DATA = ("dpRequsetNotDisturbData", 75)
59+
REQUEST_NOT_DISTURB_DATA = ("dpRequestNotDisturbData", 75) # NOTE: typo "dpRequsetNotDisturbData" in source code
6060
CARPET_CLEAN_TYPE = ("dpCarpetCleanType", 76)
6161
BUTTON_LIGHT_SWITCH = ("dpButtonLightSwitch", 77)
6262
CLEAN_LINE = ("dpCleanLine", 78)
@@ -68,7 +68,7 @@ class B01_Q10_DP(RoborockModeEnum):
6868
LOG_SWITCH = ("dpLogSwitch", 84)
6969
FLOOR_MATERIAL = ("dpFloorMaterial", 85)
7070
LINE_LASER_OBSTACLE_AVOIDANCE = ("dpLineLaserObstacleAvoidance", 86)
71-
CLEAN_PROGESS = ("dpCleanProgess", 87)
71+
CLEAN_PROGRESS = ("dpCleanProgress", 87) # NOTE: typo "dpCleanProgess" in source code
7272
GROUND_CLEAN = ("dpGroundClean", 88)
7373
IGNORE_OBSTACLE = ("dpIgnoreObstacle", 89)
7474
FAULT = ("dpFault", 90)
@@ -84,7 +84,7 @@ class B01_Q10_DP(RoborockModeEnum):
8484
SUSPECTED_THRESHOLD_UP = ("dpSuspectedThresholdUp", 100)
8585
COMMON = ("dpCommon", 101)
8686
JUMP_SCAN = ("dpJumpScan", 101)
87-
REQUETDPS = ("dpRequetdps", 102) # NOTE: THIS TYPO IS FOUND IN SOURCE CODE
87+
REQUEST_DPS = ("dpRequestDps", 102) # NOTE: typo "dpRequetdps" in source code
8888
CLIFF_RESTRICTED_AREA = ("dpCliffRestrictedArea", 102)
8989
CLIFF_RESTRICTED_AREA_UP = ("dpCliffRestrictedAreaUp", 103)
9090
BREAKPOINT_CLEAN = ("dpBreakpointClean", 104)
@@ -96,7 +96,7 @@ class B01_Q10_DP(RoborockModeEnum):
9696
HEARTBEAT = ("dpHeartbeat", 110)
9797
STATUS = ("dpStatus", 121)
9898
BATTERY = ("dpBattery", 122)
99-
FUN_LEVEL = ("dpfunLevel", 123)
99+
FAN_LEVEL = ("dpFanLevel", 123) # NOTE: typo "dpfunLevel" in source code
100100
WATER_LEVEL = ("dpWaterLevel", 124)
101101
MAIN_BRUSH_LIFE = ("dpMainBrushLife", 125)
102102
SIDE_BRUSH_LIFE = ("dpSideBrushLife", 126)
@@ -125,7 +125,7 @@ class YXFanLevel(RoborockModeEnum):
125125
NORMAL = "normal", 2
126126
STRONG = "strong", 3
127127
MAX = "max", 4
128-
SUPER = "super", 5
128+
SUPER = "super", 8
129129

130130

131131
class YXWaterLevel(RoborockModeEnum):

roborock/data/b01_q7/b01_q7_containers.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import datetime
2+
import json
13
from dataclasses import dataclass, field
4+
from functools import cached_property
25

6+
from ...exceptions import RoborockException
37
from ..containers import RoborockBase
48
from .b01_q7_code_mappings import (
59
B01Fault,
@@ -217,3 +221,81 @@ def repeat_state_name(self) -> str | None:
217221
def clean_path_preference_name(self) -> str | None:
218222
"""Returns the name of the current clean path preference."""
219223
return self.clean_path_preference.value if self.clean_path_preference is not None else None
224+
225+
@dataclass
226+
class CleanRecordDetail(RoborockBase):
227+
"""Represents a single clean record detail (from `record_list[].detail`)."""
228+
229+
record_start_time: int | None = None
230+
method: int | None = None
231+
record_use_time: int | None = None
232+
clean_count: int | None = None
233+
# This is seemingly returned in meters (non-squared)
234+
record_clean_area: int | None = None
235+
record_clean_mode: int | None = None
236+
record_clean_way: int | None = None
237+
record_task_status: int | None = None
238+
record_faultcode: int | None = None
239+
record_dust_num: int | None = None
240+
clean_current_map: int | None = None
241+
record_map_url: str | None = None
242+
243+
@property
244+
def start_datetime(self) -> datetime.datetime | None:
245+
"""Convert the start datetime into a datetime object."""
246+
if self.record_start_time is not None:
247+
return datetime.datetime.fromtimestamp(self.record_start_time).astimezone(datetime.UTC)
248+
return None
249+
250+
@property
251+
def square_meters_area_cleaned(self) -> float | None:
252+
"""Returns the area cleaned in square meters."""
253+
if self.record_clean_area is not None:
254+
return self.record_clean_area / 100
255+
return None
256+
257+
258+
@dataclass
259+
class CleanRecordListItem(RoborockBase):
260+
"""Represents an entry in the clean record list returned by `service.get_record_list`."""
261+
262+
url: str | None = None
263+
detail: str | None = None
264+
265+
@cached_property
266+
def detail_parsed(self) -> CleanRecordDetail | None:
267+
"""Parse and return the detail as a CleanRecordDetail object."""
268+
if self.detail is None:
269+
return None
270+
try:
271+
parsed = json.loads(self.detail)
272+
except json.JSONDecodeError as ex:
273+
raise RoborockException(f"Invalid B01 record detail JSON: {self.detail!r}") from ex
274+
return CleanRecordDetail.from_dict(parsed)
275+
276+
277+
@dataclass
278+
class CleanRecordList(RoborockBase):
279+
"""Represents the clean record list response from `service.get_record_list`."""
280+
281+
total_area: int | None = None
282+
total_time: int | None = None # stored in seconds
283+
total_count: int | None = None
284+
record_list: list[CleanRecordListItem] = field(default_factory=list)
285+
286+
@property
287+
def square_meters_area_cleaned(self) -> float | None:
288+
"""Returns the area cleaned in square meters."""
289+
if self.total_area is not None:
290+
return self.total_area / 100
291+
return None
292+
293+
294+
@dataclass
295+
class CleanRecordSummary(RoborockBase):
296+
"""Represents clean record totals for B01/Q7 devices."""
297+
298+
total_time: int | None = None
299+
total_area: int | None = None
300+
total_count: int | None = None
301+
last_record_detail: CleanRecordDetail | None = None

roborock/data/code_mappings.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ def from_code(cls, code: int) -> Self:
7777
raise ValueError(message)
7878

7979
@classmethod
80-
def from_code_optional(cls, code: int) -> RoborockModeEnum | None:
80+
def from_code_optional(cls, code: int) -> Self | None:
81+
"""Gracefully return None if the code does not exist."""
8182
try:
8283
return cls.from_code(code)
8384
except ValueError:
@@ -173,8 +174,10 @@ class RoborockCategory(Enum):
173174
WET_DRY_VAC = "roborock.wetdryvac"
174175
VACUUM = "robot.vacuum.cleaner"
175176
WASHING_MACHINE = "roborock.wm"
177+
MOWER = "roborock.mower"
176178
UNKNOWN = "UNKNOWN"
177179

178-
def __missing__(self, key):
179-
_LOGGER.warning("Missing key %s from category", key)
180+
@classmethod
181+
def _missing_(cls, value):
182+
_LOGGER.warning("Missing code %s from category", value)
180183
return RoborockCategory.UNKNOWN

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
"""Traits for Q10 B01 devices."""
22

3-
from typing import Any
4-
5-
from roborock.devices.rpc.b01_q7_channel import send_decoded_command
63
from roborock.devices.traits import Trait
74
from roborock.devices.transport.mqtt_channel import MqttChannel
85

96
from .command import CommandTrait
7+
from .vacuum import VacuumTrait
108

119
__all__ = [
1210
"Q10PropertiesApi",
@@ -19,9 +17,13 @@ class Q10PropertiesApi(Trait):
1917
command: CommandTrait
2018
"""Trait for sending commands to Q10 devices."""
2119

20+
vacuum: VacuumTrait
21+
"""Trait for sending vacuum related commands to Q10 devices."""
22+
2223
def __init__(self, channel: MqttChannel) -> None:
2324
"""Initialize the B01Props API."""
2425
self.command = CommandTrait(channel)
26+
self.vacuum = VacuumTrait(self.command)
2527

2628

2729
def create(channel: MqttChannel) -> Q10PropertiesApi:
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Traits for Q10 B01 devices."""
2+
3+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
4+
5+
from .command import CommandTrait
6+
7+
8+
class VacuumTrait:
9+
"""Trait for sending vacuum commands.
10+
11+
This is a wrapper around the CommandTrait for sending vacuum related
12+
commands to Q10 devices.
13+
"""
14+
15+
def __init__(self, command: CommandTrait) -> None:
16+
"""Initialize the VacuumTrait."""
17+
self._command = command
18+
19+
async def start_clean(self) -> None:
20+
"""Start cleaning."""
21+
await self._command.send(
22+
command=B01_Q10_DP.START_CLEAN,
23+
# TODO: figure out other commands
24+
# 1 = start cleaning
25+
# 2 = "electoral" clean, also has "clean_parameters"
26+
# 4 = fast create map
27+
params={"cmd": 1},
28+
)
29+
30+
async def pause_clean(self) -> None:
31+
"""Pause cleaning."""
32+
await self._command.send(
33+
command=B01_Q10_DP.PAUSE,
34+
params={},
35+
)
36+
37+
async def resume_clean(self) -> None:
38+
"""Resume cleaning."""
39+
await self._command.send(
40+
command=B01_Q10_DP.RESUME,
41+
params={},
42+
)
43+
44+
async def stop_clean(self) -> None:
45+
"""Stop cleaning."""
46+
await self._command.send(
47+
command=B01_Q10_DP.STOP,
48+
params={},
49+
)
50+
51+
async def return_to_dock(self) -> None:
52+
"""Return to dock."""
53+
await self._command.send(
54+
command=B01_Q10_DP.START_DOCK_TASK,
55+
params={},
56+
)

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,24 @@
2020
from roborock.roborock_message import RoborockB01Props
2121
from roborock.roborock_typing import RoborockB01Q7Methods
2222

23+
from .clean_summary import CleanSummaryTrait
24+
2325
__all__ = [
2426
"Q7PropertiesApi",
27+
"CleanSummaryTrait",
2528
]
2629

2730

2831
class Q7PropertiesApi(Trait):
2932
"""API for interacting with B01 devices."""
3033

34+
clean_summary: CleanSummaryTrait
35+
"""Trait for clean records / clean summary (Q7 `service.get_record_list`)."""
36+
3137
def __init__(self, channel: MqttChannel) -> None:
3238
"""Initialize the B01Props API."""
3339
self._channel = channel
40+
self.clean_summary = CleanSummaryTrait(channel)
3441

3542
async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
3643
"""Query the device for the values of the given Q7 properties."""
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Clean summary / clean records trait for B01 Q7 devices.
2+
3+
For B01/Q7, the Roborock app uses `service.get_record_list` which returns totals
4+
and a `record_list` whose items contain a JSON string in `detail`.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import logging
10+
11+
from roborock import CleanRecordDetail, CleanRecordList, CleanRecordSummary
12+
from roborock.devices.rpc.b01_q7_channel import send_decoded_command
13+
from roborock.devices.traits import Trait
14+
from roborock.devices.transport.mqtt_channel import MqttChannel
15+
from roborock.exceptions import RoborockException
16+
from roborock.protocols.b01_q7_protocol import Q7RequestMessage
17+
from roborock.roborock_typing import RoborockB01Q7Methods
18+
19+
__all__ = [
20+
"CleanSummaryTrait",
21+
]
22+
23+
_LOGGER = logging.getLogger(__name__)
24+
25+
26+
class CleanSummaryTrait(CleanRecordSummary, Trait):
27+
"""B01/Q7 clean summary + clean record access (via record list service)."""
28+
29+
def __init__(self, channel: MqttChannel) -> None:
30+
"""Initialize the clean summary trait.
31+
32+
Args:
33+
channel: MQTT channel used to communicate with the device.
34+
"""
35+
super().__init__()
36+
self._channel = channel
37+
38+
async def refresh(self) -> None:
39+
"""Refresh totals and last record detail from the device."""
40+
record_list = await self._get_record_list()
41+
42+
self.total_time = record_list.total_time
43+
self.total_area = record_list.total_area
44+
self.total_count = record_list.total_count
45+
46+
details = await self._get_clean_record_details(record_list=record_list)
47+
self.last_record_detail = details[0] if details else None
48+
49+
async def _get_record_list(self) -> CleanRecordList:
50+
"""Fetch the raw device clean record list (`service.get_record_list`)."""
51+
result = await send_decoded_command(
52+
self._channel,
53+
Q7RequestMessage(dps=10000, command=RoborockB01Q7Methods.GET_RECORD_LIST, params={}),
54+
)
55+
56+
if not isinstance(result, dict):
57+
raise TypeError(f"Unexpected response type for GET_RECORD_LIST: {type(result).__name__}: {result!r}")
58+
return CleanRecordList.from_dict(result)
59+
60+
async def _get_clean_record_details(self, *, record_list: CleanRecordList) -> list[CleanRecordDetail]:
61+
"""Return parsed record detail objects (newest-first)."""
62+
details: list[CleanRecordDetail] = []
63+
for item in record_list.record_list:
64+
try:
65+
parsed = item.detail_parsed
66+
except RoborockException as ex:
67+
# Rather than failing if something goes wrong here, we should fail and log to tell the user.
68+
_LOGGER.debug("Failed to parse record detail: %s", ex)
69+
continue
70+
if parsed is not None:
71+
details.append(parsed)
72+
73+
# The server returns the newest record at the end of record_list; reverse so newest is first (index 0).
74+
details.reverse()
75+
return details

0 commit comments

Comments
 (0)