Skip to content

Commit 1cd66b8

Browse files
committed
chore: testing and PR comments
1 parent b27092e commit 1cd66b8

File tree

8 files changed

+310
-99
lines changed

8 files changed

+310
-99
lines changed

roborock/data/b01_q7/b01_q7_containers.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import datetime
12
from dataclasses import dataclass, field
23

34
from ..containers import RoborockBase
@@ -213,6 +214,7 @@ class CleanRecordDetail(RoborockBase):
213214
method: int | None = None
214215
record_use_time: int | None = None
215216
clean_count: int | None = None
217+
# This is seemingly returned in meters (non-squared)
216218
record_clean_area: int | None = None
217219
record_clean_mode: int | None = None
218220
record_clean_way: int | None = None
@@ -222,24 +224,45 @@ class CleanRecordDetail(RoborockBase):
222224
clean_current_map: int | None = None
223225
record_map_url: str | None = None
224226

227+
@property
228+
def start_datetime(self) -> datetime.datetime | None:
229+
"""Convert the start datetime into a datetime object."""
230+
if self.record_start_time is not None:
231+
return datetime.datetime.fromtimestamp(self.record_start_time).astimezone(datetime.UTC)
232+
return None
233+
234+
@property
235+
def square_meters_area_cleaned(self) -> float | None:
236+
"""Returns the area cleaned in square meters."""
237+
if self.record_clean_area is not None:
238+
return self.record_clean_area / 100
239+
return None
240+
225241

226242
@dataclass
227243
class CleanRecordListItem(RoborockBase):
228244
"""Represents an entry in the clean record list returned by `service.get_record_list`."""
229245

230246
url: str | None = None
231-
detail: str | dict | None = None
247+
detail: str | None = None
232248

233249

234250
@dataclass
235251
class CleanRecordList(RoborockBase):
236252
"""Represents the clean record list response from `service.get_record_list`."""
237253

238254
total_area: int | None = None
239-
total_time: int | None = None
255+
total_time: int | None = None # stored in seconds
240256
total_count: int | None = None
241257
record_list: list[CleanRecordListItem] = field(default_factory=list)
242258

259+
@property
260+
def square_meters_area_cleaned(self) -> float | None:
261+
"""Returns the area cleaned in square meters."""
262+
if self.total_area is not None:
263+
return self.total_area / 100
264+
return None
265+
243266

244267
@dataclass
245268
class CleanRecordSummary(RoborockBase):

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

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,16 @@ def __init__(self, channel: MqttChannel) -> None:
3535

3636
async def refresh(self) -> None:
3737
"""Refresh totals and last record detail from the device."""
38-
record_list = await self.get_record_list()
38+
record_list = await self._get_record_list()
3939

4040
self.total_time = record_list.total_time
4141
self.total_area = record_list.total_area
4242
self.total_count = record_list.total_count
4343

44-
details = await self.get_clean_record_details(record_list=record_list)
44+
details = await self._get_clean_record_details(record_list=record_list)
4545
self.last_record_detail = details[0] if details else None
4646

47-
async def get_record_list(self) -> CleanRecordList:
47+
async def _get_record_list(self) -> CleanRecordList:
4848
"""Fetch the raw device clean record list (`service.get_record_list`)."""
4949
result = await send_decoded_command(
5050
self._channel,
@@ -55,33 +55,21 @@ async def get_record_list(self) -> CleanRecordList:
5555
raise TypeError(f"Unexpected response type for GET_RECORD_LIST: {type(result).__name__}: {result!r}")
5656
return CleanRecordList.from_dict(result)
5757

58-
@staticmethod
59-
def _parse_record_detail(detail: dict | str | None) -> CleanRecordDetail | None:
60-
if detail is None:
61-
return None
62-
if isinstance(detail, str):
63-
try:
64-
parsed = json.loads(detail)
65-
except json.JSONDecodeError as ex:
66-
raise RoborockException(f"Invalid B01 record detail JSON: {detail!r}") from ex
67-
if not isinstance(parsed, dict):
68-
raise RoborockException(f"Unexpected B01 record detail type: {type(parsed).__name__}: {parsed!r}")
69-
return CleanRecordDetail.from_dict(parsed)
70-
if isinstance(detail, dict):
71-
return CleanRecordDetail.from_dict(detail)
72-
raise RoborockException(f"Unexpected B01 record detail type: {type(detail).__name__}: {detail!r}")
73-
74-
async def get_clean_record_details(self, *, record_list: CleanRecordList | None = None) -> list[CleanRecordDetail]:
58+
async def _get_clean_record_details(self, *, record_list: CleanRecordList) -> list[CleanRecordDetail]:
7559
"""Return parsed record detail objects (newest-first)."""
76-
if record_list is None:
77-
record_list = await self.get_record_list()
78-
7960
details: list[CleanRecordDetail] = []
8061
for item in record_list.record_list:
81-
parsed = self._parse_record_detail(item.detail)
62+
if item.detail is None:
63+
continue
64+
try:
65+
parsed = json.loads(item.detail)
66+
except json.JSONDecodeError as ex:
67+
raise RoborockException(f"Invalid B01 record detail JSON: {item.detail!r}") from ex
68+
parsed = CleanRecordDetail.from_dict(parsed)
69+
8270
if parsed is not None:
8371
details.append(parsed)
8472

85-
# The app returns the newest record at the end of record_list; reverse so newest is first (index 0).
73+
# The server returns the newest record at the end of record_list; reverse so newest is first (index 0).
8674
details.reverse()
8775
return details

tests/data/b01_q7/test_b01_q7_containers.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
SCWindMapping,
1111
WorkStatusMapping,
1212
)
13-
from roborock.devices.traits.b01.q7.clean_summary import CleanSummaryTrait
1413

1514

1615
def test_b01props_deserialization():
@@ -141,8 +140,15 @@ def test_b01_q7_clean_record_list_parses_detail_fields():
141140
assert isinstance(parsed, CleanRecordList)
142141
assert parsed.record_list[0].url == "/userdata/record_map/1766368207_1766368283_0_clean_map.bin"
143142

144-
detail = CleanSummaryTrait._parse_record_detail(parsed.record_list[0].detail)
143+
detail_dict = json.loads(parsed.record_list[0].detail or "{}")
144+
detail = CleanRecordDetail.from_dict(detail_dict)
145145
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"
146152
assert detail.method == 0
147153
assert detail.clean_count == 1
148154
assert detail.record_clean_way == 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)