Skip to content

Commit 829d338

Browse files
authored
Merge branch 'main' into feat/q10-cli-commands
2 parents c38fda8 + 9993407 commit 829d338

File tree

11 files changed

+4088
-71
lines changed

11 files changed

+4088
-71
lines changed

CHANGELOG.md

Lines changed: 3600 additions & 0 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "python-roborock"
3-
version = "2.25.0"
3+
version = "4.10.0"
44
description = "A package to control Roborock vacuums."
55
authors = ["humbertogontijo <humbertogontijo@users.noreply.github.com>"]
66
license = "GPL-3.0-only"

roborock/data/b01_q7/b01_q7_containers.py

Lines changed: 83 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,
@@ -205,3 +209,82 @@ def wind_name(self) -> str | None:
205209
def work_mode_name(self) -> str | None:
206210
"""Returns the name of the current work mode."""
207211
return self.work_mode.value if self.work_mode is not None else None
212+
213+
214+
@dataclass
215+
class CleanRecordDetail(RoborockBase):
216+
"""Represents a single clean record detail (from `record_list[].detail`)."""
217+
218+
record_start_time: int | None = None
219+
method: int | None = None
220+
record_use_time: int | None = None
221+
clean_count: int | None = None
222+
# This is seemingly returned in meters (non-squared)
223+
record_clean_area: int | None = None
224+
record_clean_mode: int | None = None
225+
record_clean_way: int | None = None
226+
record_task_status: int | None = None
227+
record_faultcode: int | None = None
228+
record_dust_num: int | None = None
229+
clean_current_map: int | None = None
230+
record_map_url: str | None = None
231+
232+
@property
233+
def start_datetime(self) -> datetime.datetime | None:
234+
"""Convert the start datetime into a datetime object."""
235+
if self.record_start_time is not None:
236+
return datetime.datetime.fromtimestamp(self.record_start_time).astimezone(datetime.UTC)
237+
return None
238+
239+
@property
240+
def square_meters_area_cleaned(self) -> float | None:
241+
"""Returns the area cleaned in square meters."""
242+
if self.record_clean_area is not None:
243+
return self.record_clean_area / 100
244+
return None
245+
246+
247+
@dataclass
248+
class CleanRecordListItem(RoborockBase):
249+
"""Represents an entry in the clean record list returned by `service.get_record_list`."""
250+
251+
url: str | None = None
252+
detail: str | None = None
253+
254+
@cached_property
255+
def detail_parsed(self) -> CleanRecordDetail | None:
256+
"""Parse and return the detail as a CleanRecordDetail object."""
257+
if self.detail is None:
258+
return None
259+
try:
260+
parsed = json.loads(self.detail)
261+
except json.JSONDecodeError as ex:
262+
raise RoborockException(f"Invalid B01 record detail JSON: {self.detail!r}") from ex
263+
return CleanRecordDetail.from_dict(parsed)
264+
265+
266+
@dataclass
267+
class CleanRecordList(RoborockBase):
268+
"""Represents the clean record list response from `service.get_record_list`."""
269+
270+
total_area: int | None = None
271+
total_time: int | None = None # stored in seconds
272+
total_count: int | None = None
273+
record_list: list[CleanRecordListItem] = field(default_factory=list)
274+
275+
@property
276+
def square_meters_area_cleaned(self) -> float | None:
277+
"""Returns the area cleaned in square meters."""
278+
if self.total_area is not None:
279+
return self.total_area / 100
280+
return None
281+
282+
283+
@dataclass
284+
class CleanRecordSummary(RoborockBase):
285+
"""Represents clean record totals for B01/Q7 devices."""
286+
287+
total_time: int | None = None
288+
total_area: int | None = None
289+
total_count: int | None = None
290+
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
@@ -18,17 +18,24 @@
1818
from roborock.roborock_message import RoborockB01Props
1919
from roborock.roborock_typing import RoborockB01Q7Methods
2020

21+
from .clean_summary import CleanSummaryTrait
22+
2123
__all__ = [
2224
"Q7PropertiesApi",
25+
"CleanSummaryTrait",
2326
]
2427

2528

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

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

3340
async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
3441
"""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

tests/data/b01_q7/test_b01_q7_containers.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
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
)
@@ -102,3 +106,52 @@ def test_b01props_deserialization():
102106
assert deserialized.wind == SCWindMapping.STRONG
103107
assert deserialized.net_status is not None
104108
assert deserialized.net_status.ip == "192.168.1.102"
109+
110+
111+
def test_b01_q7_clean_record_list_parses_detail_fields():
112+
payload = {
113+
"total_time": 34980,
114+
"total_area": 28540,
115+
"total_count": 1,
116+
"record_list": [
117+
{
118+
"url": "/userdata/record_map/1766368207_1766368283_0_clean_map.bin",
119+
"detail": json.dumps(
120+
{
121+
"record_start_time": 1766368207,
122+
"method": 0,
123+
"record_use_time": 60,
124+
"clean_count": 1,
125+
"record_clean_area": 85,
126+
"record_clean_mode": 0,
127+
"record_clean_way": 0,
128+
"record_task_status": 20,
129+
"record_faultcode": 0,
130+
"record_dust_num": 0,
131+
"clean_current_map": 0,
132+
"record_map_url": "/userdata/record_map/1766368207_1766368283_0_clean_map.bin",
133+
}
134+
),
135+
}
136+
],
137+
}
138+
139+
parsed = CleanRecordList.from_dict(payload)
140+
assert isinstance(parsed, CleanRecordList)
141+
assert parsed.record_list[0].url == "/userdata/record_map/1766368207_1766368283_0_clean_map.bin"
142+
143+
detail_dict = json.loads(parsed.record_list[0].detail or "{}")
144+
detail = CleanRecordDetail.from_dict(detail_dict)
145+
assert isinstance(detail, CleanRecordDetail)
146+
assert detail.record_start_time == 1766368207
147+
assert detail.record_use_time == 60
148+
assert detail.record_clean_area == 85
149+
assert detail.record_clean_mode == 0
150+
assert detail.record_task_status == 20
151+
assert detail.record_map_url == "/userdata/record_map/1766368207_1766368283_0_clean_map.bin"
152+
assert detail.method == 0
153+
assert detail.clean_count == 1
154+
assert detail.record_clean_way == 0
155+
assert detail.record_faultcode == 0
156+
assert detail.record_dust_num == 0
157+
assert detail.clean_current_map == 0
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import json
2+
from typing import Any
3+
4+
from Crypto.Cipher import AES
5+
from Crypto.Util.Padding import pad
6+
7+
from roborock.devices.traits.b01.q7 import Q7PropertiesApi
8+
from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
9+
from tests.fixtures.channel_fixtures import FakeChannel
10+
11+
12+
class B01MessageBuilder:
13+
"""Helper class to build B01 RPC response messages for tests."""
14+
15+
def __init__(self) -> None:
16+
self.msg_id = 123456789
17+
self.seq = 2020
18+
19+
def build(self, data: dict[str, Any] | str, code: int | None = None) -> RoborockMessage:
20+
"""Build an encoded B01 RPC response message."""
21+
message: dict[str, Any] = {
22+
"msgId": str(self.msg_id),
23+
"data": data,
24+
}
25+
if code is not None:
26+
message["code"] = code
27+
return self._build_dps(message)
28+
29+
def _build_dps(self, message: dict[str, Any] | str) -> RoborockMessage:
30+
"""Build an encoded B01 RPC response message."""
31+
dps_payload = {"dps": {"10000": json.dumps(message)}}
32+
self.seq += 1
33+
return RoborockMessage(
34+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
35+
payload=pad(
36+
json.dumps(dps_payload).encode(),
37+
AES.block_size,
38+
),
39+
version=b"B01",
40+
seq=self.seq,
41+
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import math
2+
import time
3+
from collections.abc import Generator
4+
from unittest.mock import patch
5+
6+
import pytest
7+
8+
from roborock.devices.traits.b01.q7 import Q7PropertiesApi
9+
from tests.fixtures.channel_fixtures import FakeChannel
10+
11+
from . import B01MessageBuilder
12+
13+
14+
@pytest.fixture(name="fake_channel")
15+
def fake_channel_fixture() -> FakeChannel:
16+
return FakeChannel()
17+
18+
19+
@pytest.fixture(name="q7_api")
20+
def q7_api_fixture(fake_channel: FakeChannel) -> Q7PropertiesApi:
21+
return Q7PropertiesApi(fake_channel) # type: ignore[arg-type]
22+
23+
24+
@pytest.fixture(name="expected_msg_id", autouse=True)
25+
def next_message_id_fixture() -> Generator[int, None, None]:
26+
"""Fixture to patch get_next_int to return the expected message ID.
27+
28+
We pick an arbitrary number, but just need it to ensure we can craft a fake
29+
response with the message id matched to the outgoing RPC.
30+
"""
31+
expected_msg_id = math.floor(time.time())
32+
33+
# Patch get_next_int to return our expected msg_id so the channel waits for it
34+
with patch("roborock.protocols.b01_q7_protocol.get_next_int", return_value=expected_msg_id):
35+
yield expected_msg_id
36+
37+
38+
@pytest.fixture(name="message_builder")
39+
def message_builder_fixture(expected_msg_id: int) -> B01MessageBuilder:
40+
builder = B01MessageBuilder()
41+
builder.msg_id = expected_msg_id
42+
return builder

0 commit comments

Comments
 (0)