Skip to content

Commit d4c0772

Browse files
committed
feat: add clean record for Q7
1 parent a69286f commit d4c0772

File tree

4 files changed

+182
-0
lines changed

4 files changed

+182
-0
lines changed

roborock/data/b01_q7/b01_q7_containers.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,49 @@ def wind_name(self) -> str | None:
203203
def work_mode_name(self) -> str | None:
204204
"""Returns the name of the current work mode."""
205205
return self.work_mode.value if self.work_mode is not None else None
206+
207+
208+
@dataclass
209+
class CleanRecordDetail(RoborockBase):
210+
"""Represents a single clean record detail (from `record_list[].detail`)."""
211+
212+
record_start_time: int | None = None
213+
method: int | None = None
214+
record_use_time: int | None = None
215+
clean_count: int | None = None
216+
record_clean_area: int | None = None
217+
record_clean_mode: int | None = None
218+
record_clean_way: int | None = None
219+
record_task_status: int | None = None
220+
record_faultcode: int | None = None
221+
record_dust_num: int | None = None
222+
clean_current_map: int | None = None
223+
record_map_url: str | None = None
224+
225+
226+
@dataclass
227+
class CleanRecordListItem(RoborockBase):
228+
"""Represents an entry in the clean record list returned by `service.get_record_list`."""
229+
230+
url: str | None = None
231+
detail: str | dict | None = None
232+
233+
234+
@dataclass
235+
class CleanRecordList(RoborockBase):
236+
"""Represents the clean record list response from `service.get_record_list`."""
237+
238+
total_area: int | None = None
239+
total_time: int | None = None
240+
total_count: int | None = None
241+
record_list: list[CleanRecordListItem] = field(default_factory=list)
242+
243+
244+
@dataclass
245+
class CleanRecordSummary(RoborockBase):
246+
"""Represents clean record totals for B01/Q7 devices."""
247+
248+
total_area: int | None = None
249+
total_time: int | None = None
250+
total_count: int | None = None
251+
last_record_detail: CleanRecordDetail | None = None

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,24 @@
1717
from roborock.roborock_message import RoborockB01Props
1818
from roborock.roborock_typing import RoborockB01Q7Methods
1919

20+
from .clean_summary import CleanSummaryTrait
21+
2022
__all__ = [
2123
"Q7PropertiesApi",
24+
"CleanSummaryTrait",
2225
]
2326

2427

2528
class Q7PropertiesApi(Trait):
2629
"""API for interacting with B01 devices."""
2730

31+
clean_summary: CleanSummaryTrait
32+
"""Trait for clean records / clean summary (Q7 `service.get_record_list`)."""
33+
2834
def __init__(self, channel: MqttChannel) -> None:
2935
"""Initialize the B01Props API."""
3036
self._channel = channel
37+
self.clean_summary = CleanSummaryTrait(channel)
3138

3239
async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
3340
"""Query the device for the values of the given Q7 properties."""
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 json
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+
24+
class CleanSummaryTrait(CleanRecordSummary, Trait):
25+
"""B01/Q7 clean summary + clean record access (via record list service)."""
26+
27+
def __init__(self, channel: MqttChannel) -> None:
28+
super().__init__()
29+
self._channel = channel
30+
31+
async def refresh(self) -> None:
32+
"""Refresh totals and last record detail from the device."""
33+
record_list = await self.get_record_list()
34+
35+
self.total_time = record_list.total_time
36+
self.total_area = record_list.total_area
37+
self.total_count = record_list.total_count
38+
39+
details = await self.get_clean_record_details(record_list=record_list)
40+
self.last_record_detail = details[0] if details else None
41+
42+
async def get_record_list(self) -> CleanRecordList:
43+
"""Fetch the raw device clean record list (`service.get_record_list`)."""
44+
result = await send_decoded_command(
45+
self._channel,
46+
Q7RequestMessage(dps=10000, command=RoborockB01Q7Methods.GET_RECORD_LIST, params={}),
47+
)
48+
49+
if not isinstance(result, dict):
50+
raise TypeError(f"Unexpected response type for GET_RECORD_LIST: {type(result).__name__}: {result!r}")
51+
return CleanRecordList.from_dict(result)
52+
53+
@staticmethod
54+
def _parse_record_detail(detail: dict | str | None) -> CleanRecordDetail | None:
55+
if detail is None:
56+
return None
57+
if isinstance(detail, str):
58+
try:
59+
parsed = json.loads(detail)
60+
except json.JSONDecodeError as ex:
61+
raise RoborockException(f"Invalid B01 record detail JSON: {detail!r}") from ex
62+
if not isinstance(parsed, dict):
63+
raise RoborockException(f"Unexpected B01 record detail type: {type(parsed).__name__}: {parsed!r}")
64+
return CleanRecordDetail.from_dict(parsed)
65+
if isinstance(detail, dict):
66+
return CleanRecordDetail.from_dict(detail)
67+
raise TypeError(f"Unexpected B01 record detail type: {type(detail).__name__}: {detail!r}")
68+
69+
async def get_clean_record_details(self, *, record_list: CleanRecordList | None = None) -> list[CleanRecordDetail]:
70+
"""Return parsed record detail objects (newest-first)."""
71+
if record_list is None:
72+
record_list = await self.get_record_list()
73+
74+
details: list[CleanRecordDetail] = []
75+
for item in record_list.record_list:
76+
parsed = self._parse_record_detail(item.detail)
77+
if parsed is not None:
78+
details.append(parsed)
79+
80+
# App treats the newest record as the end of the list
81+
details.reverse()
82+
return details

tests/data/b01_q7/test_b01_q7_containers.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
"""Test cases for the containers module."""
22

3+
import json
4+
35
from roborock.data.b01_q7 import (
46
B01Fault,
57
B01Props,
8+
CleanRecordDetail,
9+
CleanRecordList,
610
SCWindMapping,
711
WorkStatusMapping,
812
)
13+
from roborock.devices.traits.b01.q7.clean_summary import CleanSummaryTrait
914

1015

1116
def test_b01props_deserialization():
@@ -102,3 +107,45 @@ def test_b01props_deserialization():
102107
assert deserialized.wind == SCWindMapping.STRONG
103108
assert deserialized.net_status is not None
104109
assert deserialized.net_status.ip == "192.168.1.102"
110+
111+
112+
def test_b01_q7_clean_record_list_parses_detail_fields():
113+
payload = {
114+
"total_time": 34980,
115+
"total_area": 28540,
116+
"total_count": 1,
117+
"record_list": [
118+
{
119+
"url": "/userdata/record_map/1766368207_1766368283_0_clean_map.bin",
120+
"detail": json.dumps(
121+
{
122+
"record_start_time": 1766368207,
123+
"method": 0,
124+
"record_use_time": 60,
125+
"clean_count": 1,
126+
"record_clean_area": 85,
127+
"record_clean_mode": 0,
128+
"record_clean_way": 0,
129+
"record_task_status": 20,
130+
"record_faultcode": 0,
131+
"record_dust_num": 0,
132+
"clean_current_map": 0,
133+
"record_map_url": "/userdata/record_map/1766368207_1766368283_0_clean_map.bin",
134+
}
135+
),
136+
}
137+
],
138+
}
139+
140+
parsed = CleanRecordList.from_dict(payload)
141+
assert isinstance(parsed, CleanRecordList)
142+
assert parsed.record_list[0].url == "/userdata/record_map/1766368207_1766368283_0_clean_map.bin"
143+
144+
detail = CleanSummaryTrait._parse_record_detail(parsed.record_list[0].detail)
145+
assert isinstance(detail, CleanRecordDetail)
146+
assert detail.method == 0
147+
assert detail.clean_count == 1
148+
assert detail.record_clean_way == 0
149+
assert detail.record_faultcode == 0
150+
assert detail.record_dust_num == 0
151+
assert detail.clean_current_map == 0

0 commit comments

Comments
 (0)