From d4c0772bf35cf69b401bed59563a04823049cb37 Mon Sep 17 00:00:00 2001 From: Luke Date: Thu, 15 Jan 2026 22:15:58 -0500 Subject: [PATCH 1/4] feat: add clean record for Q7 --- roborock/data/b01_q7/b01_q7_containers.py | 46 +++++++++++ roborock/devices/traits/b01/q7/__init__.py | 7 ++ .../devices/traits/b01/q7/clean_summary.py | 82 +++++++++++++++++++ tests/data/b01_q7/test_b01_q7_containers.py | 47 +++++++++++ 4 files changed, 182 insertions(+) create mode 100644 roborock/devices/traits/b01/q7/clean_summary.py diff --git a/roborock/data/b01_q7/b01_q7_containers.py b/roborock/data/b01_q7/b01_q7_containers.py index a9b8392d..a10b18ca 100644 --- a/roborock/data/b01_q7/b01_q7_containers.py +++ b/roborock/data/b01_q7/b01_q7_containers.py @@ -203,3 +203,49 @@ def wind_name(self) -> str | None: def work_mode_name(self) -> str | None: """Returns the name of the current work mode.""" return self.work_mode.value if self.work_mode is not None else None + + +@dataclass +class CleanRecordDetail(RoborockBase): + """Represents a single clean record detail (from `record_list[].detail`).""" + + record_start_time: int | None = None + method: int | None = None + record_use_time: int | None = None + clean_count: int | None = None + record_clean_area: int | None = None + record_clean_mode: int | None = None + record_clean_way: int | None = None + record_task_status: int | None = None + record_faultcode: int | None = None + record_dust_num: int | None = None + clean_current_map: int | None = None + record_map_url: str | None = None + + +@dataclass +class CleanRecordListItem(RoborockBase): + """Represents an entry in the clean record list returned by `service.get_record_list`.""" + + url: str | None = None + detail: str | dict | None = None + + +@dataclass +class CleanRecordList(RoborockBase): + """Represents the clean record list response from `service.get_record_list`.""" + + total_area: int | None = None + total_time: int | None = None + total_count: int | None = None + record_list: list[CleanRecordListItem] = field(default_factory=list) + + +@dataclass +class CleanRecordSummary(RoborockBase): + """Represents clean record totals for B01/Q7 devices.""" + + total_area: int | None = None + total_time: int | None = None + total_count: int | None = None + last_record_detail: CleanRecordDetail | None = None diff --git a/roborock/devices/traits/b01/q7/__init__.py b/roborock/devices/traits/b01/q7/__init__.py index 246fc87d..8e066250 100644 --- a/roborock/devices/traits/b01/q7/__init__.py +++ b/roborock/devices/traits/b01/q7/__init__.py @@ -17,17 +17,24 @@ from roborock.roborock_message import RoborockB01Props from roborock.roborock_typing import RoborockB01Q7Methods +from .clean_summary import CleanSummaryTrait + __all__ = [ "Q7PropertiesApi", + "CleanSummaryTrait", ] class Q7PropertiesApi(Trait): """API for interacting with B01 devices.""" + clean_summary: CleanSummaryTrait + """Trait for clean records / clean summary (Q7 `service.get_record_list`).""" + def __init__(self, channel: MqttChannel) -> None: """Initialize the B01Props API.""" self._channel = channel + self.clean_summary = CleanSummaryTrait(channel) async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None: """Query the device for the values of the given Q7 properties.""" diff --git a/roborock/devices/traits/b01/q7/clean_summary.py b/roborock/devices/traits/b01/q7/clean_summary.py new file mode 100644 index 00000000..a912ba68 --- /dev/null +++ b/roborock/devices/traits/b01/q7/clean_summary.py @@ -0,0 +1,82 @@ +"""Clean summary / clean records trait for B01 Q7 devices. + +For B01/Q7, the Roborock app uses `service.get_record_list` which returns totals +and a `record_list` whose items contain a JSON string in `detail`. +""" + +from __future__ import annotations + +import json + +from roborock import CleanRecordDetail, CleanRecordList, CleanRecordSummary +from roborock.devices.rpc.b01_q7_channel import send_decoded_command +from roborock.devices.traits import Trait +from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.exceptions import RoborockException +from roborock.protocols.b01_q7_protocol import Q7RequestMessage +from roborock.roborock_typing import RoborockB01Q7Methods + +__all__ = [ + "CleanSummaryTrait", +] + + +class CleanSummaryTrait(CleanRecordSummary, Trait): + """B01/Q7 clean summary + clean record access (via record list service).""" + + def __init__(self, channel: MqttChannel) -> None: + super().__init__() + self._channel = channel + + async def refresh(self) -> None: + """Refresh totals and last record detail from the device.""" + record_list = await self.get_record_list() + + self.total_time = record_list.total_time + self.total_area = record_list.total_area + self.total_count = record_list.total_count + + details = await self.get_clean_record_details(record_list=record_list) + self.last_record_detail = details[0] if details else None + + async def get_record_list(self) -> CleanRecordList: + """Fetch the raw device clean record list (`service.get_record_list`).""" + result = await send_decoded_command( + self._channel, + Q7RequestMessage(dps=10000, command=RoborockB01Q7Methods.GET_RECORD_LIST, params={}), + ) + + if not isinstance(result, dict): + raise TypeError(f"Unexpected response type for GET_RECORD_LIST: {type(result).__name__}: {result!r}") + return CleanRecordList.from_dict(result) + + @staticmethod + def _parse_record_detail(detail: dict | str | None) -> CleanRecordDetail | None: + if detail is None: + return None + if isinstance(detail, str): + try: + parsed = json.loads(detail) + except json.JSONDecodeError as ex: + raise RoborockException(f"Invalid B01 record detail JSON: {detail!r}") from ex + if not isinstance(parsed, dict): + raise RoborockException(f"Unexpected B01 record detail type: {type(parsed).__name__}: {parsed!r}") + return CleanRecordDetail.from_dict(parsed) + if isinstance(detail, dict): + return CleanRecordDetail.from_dict(detail) + raise TypeError(f"Unexpected B01 record detail type: {type(detail).__name__}: {detail!r}") + + async def get_clean_record_details(self, *, record_list: CleanRecordList | None = None) -> list[CleanRecordDetail]: + """Return parsed record detail objects (newest-first).""" + if record_list is None: + record_list = await self.get_record_list() + + details: list[CleanRecordDetail] = [] + for item in record_list.record_list: + parsed = self._parse_record_detail(item.detail) + if parsed is not None: + details.append(parsed) + + # App treats the newest record as the end of the list + details.reverse() + return details diff --git a/tests/data/b01_q7/test_b01_q7_containers.py b/tests/data/b01_q7/test_b01_q7_containers.py index de5b108f..768273cb 100644 --- a/tests/data/b01_q7/test_b01_q7_containers.py +++ b/tests/data/b01_q7/test_b01_q7_containers.py @@ -1,11 +1,16 @@ """Test cases for the containers module.""" +import json + from roborock.data.b01_q7 import ( B01Fault, B01Props, + CleanRecordDetail, + CleanRecordList, SCWindMapping, WorkStatusMapping, ) +from roborock.devices.traits.b01.q7.clean_summary import CleanSummaryTrait def test_b01props_deserialization(): @@ -102,3 +107,45 @@ def test_b01props_deserialization(): assert deserialized.wind == SCWindMapping.STRONG assert deserialized.net_status is not None assert deserialized.net_status.ip == "192.168.1.102" + + +def test_b01_q7_clean_record_list_parses_detail_fields(): + payload = { + "total_time": 34980, + "total_area": 28540, + "total_count": 1, + "record_list": [ + { + "url": "/userdata/record_map/1766368207_1766368283_0_clean_map.bin", + "detail": json.dumps( + { + "record_start_time": 1766368207, + "method": 0, + "record_use_time": 60, + "clean_count": 1, + "record_clean_area": 85, + "record_clean_mode": 0, + "record_clean_way": 0, + "record_task_status": 20, + "record_faultcode": 0, + "record_dust_num": 0, + "clean_current_map": 0, + "record_map_url": "/userdata/record_map/1766368207_1766368283_0_clean_map.bin", + } + ), + } + ], + } + + parsed = CleanRecordList.from_dict(payload) + assert isinstance(parsed, CleanRecordList) + assert parsed.record_list[0].url == "/userdata/record_map/1766368207_1766368283_0_clean_map.bin" + + detail = CleanSummaryTrait._parse_record_detail(parsed.record_list[0].detail) + assert isinstance(detail, CleanRecordDetail) + assert detail.method == 0 + assert detail.clean_count == 1 + assert detail.record_clean_way == 0 + assert detail.record_faultcode == 0 + assert detail.record_dust_num == 0 + assert detail.clean_current_map == 0 From b27092e78f74479a9fd797d178edb4c1a41f5def Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 31 Jan 2026 20:05:04 -0500 Subject: [PATCH 2/4] chore: some copilot suggestions Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- roborock/data/b01_q7/b01_q7_containers.py | 2 +- roborock/devices/traits/b01/q7/clean_summary.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/roborock/data/b01_q7/b01_q7_containers.py b/roborock/data/b01_q7/b01_q7_containers.py index a10b18ca..d42f388e 100644 --- a/roborock/data/b01_q7/b01_q7_containers.py +++ b/roborock/data/b01_q7/b01_q7_containers.py @@ -245,7 +245,7 @@ class CleanRecordList(RoborockBase): class CleanRecordSummary(RoborockBase): """Represents clean record totals for B01/Q7 devices.""" - total_area: int | None = None total_time: int | None = None + total_area: int | None = None total_count: int | None = None last_record_detail: CleanRecordDetail | None = None diff --git a/roborock/devices/traits/b01/q7/clean_summary.py b/roborock/devices/traits/b01/q7/clean_summary.py index a912ba68..0345d41d 100644 --- a/roborock/devices/traits/b01/q7/clean_summary.py +++ b/roborock/devices/traits/b01/q7/clean_summary.py @@ -25,6 +25,11 @@ class CleanSummaryTrait(CleanRecordSummary, Trait): """B01/Q7 clean summary + clean record access (via record list service).""" def __init__(self, channel: MqttChannel) -> None: + """Initialize the clean summary trait. + + Args: + channel: MQTT channel used to communicate with the device. + """ super().__init__() self._channel = channel @@ -64,7 +69,7 @@ def _parse_record_detail(detail: dict | str | None) -> CleanRecordDetail | None: return CleanRecordDetail.from_dict(parsed) if isinstance(detail, dict): return CleanRecordDetail.from_dict(detail) - raise TypeError(f"Unexpected B01 record detail type: {type(detail).__name__}: {detail!r}") + raise RoborockException(f"Unexpected B01 record detail type: {type(detail).__name__}: {detail!r}") async def get_clean_record_details(self, *, record_list: CleanRecordList | None = None) -> list[CleanRecordDetail]: """Return parsed record detail objects (newest-first).""" @@ -77,6 +82,6 @@ async def get_clean_record_details(self, *, record_list: CleanRecordList | None if parsed is not None: details.append(parsed) - # App treats the newest record as the end of the list + # The app returns the newest record at the end of record_list; reverse so newest is first (index 0). details.reverse() return details From 1cd66b856c6adcf8c32ae084c4f6e572d700352f Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 31 Jan 2026 22:38:00 -0500 Subject: [PATCH 3/4] chore: testing and PR comments --- roborock/data/b01_q7/b01_q7_containers.py | 27 ++- .../devices/traits/b01/q7/clean_summary.py | 38 ++-- tests/data/b01_q7/test_b01_q7_containers.py | 10 +- tests/devices/traits/b01/q7/__init__.py | 41 ++++ tests/devices/traits/b01/q7/conftest.py | 42 +++++ .../traits/b01/q7/test_clean_summary.py | 176 ++++++++++++++++++ tests/devices/traits/b01/q7/test_init.py | 71 +------ tests/mock_data.py | 4 +- 8 files changed, 310 insertions(+), 99 deletions(-) create mode 100644 tests/devices/traits/b01/q7/conftest.py create mode 100644 tests/devices/traits/b01/q7/test_clean_summary.py diff --git a/roborock/data/b01_q7/b01_q7_containers.py b/roborock/data/b01_q7/b01_q7_containers.py index d42f388e..c55aec19 100644 --- a/roborock/data/b01_q7/b01_q7_containers.py +++ b/roborock/data/b01_q7/b01_q7_containers.py @@ -1,3 +1,4 @@ +import datetime from dataclasses import dataclass, field from ..containers import RoborockBase @@ -213,6 +214,7 @@ class CleanRecordDetail(RoborockBase): method: int | None = None record_use_time: int | None = None clean_count: int | None = None + # This is seemingly returned in meters (non-squared) record_clean_area: int | None = None record_clean_mode: int | None = None record_clean_way: int | None = None @@ -222,13 +224,27 @@ class CleanRecordDetail(RoborockBase): clean_current_map: int | None = None record_map_url: str | None = None + @property + def start_datetime(self) -> datetime.datetime | None: + """Convert the start datetime into a datetime object.""" + if self.record_start_time is not None: + return datetime.datetime.fromtimestamp(self.record_start_time).astimezone(datetime.UTC) + return None + + @property + def square_meters_area_cleaned(self) -> float | None: + """Returns the area cleaned in square meters.""" + if self.record_clean_area is not None: + return self.record_clean_area / 100 + return None + @dataclass class CleanRecordListItem(RoborockBase): """Represents an entry in the clean record list returned by `service.get_record_list`.""" url: str | None = None - detail: str | dict | None = None + detail: str | None = None @dataclass @@ -236,10 +252,17 @@ class CleanRecordList(RoborockBase): """Represents the clean record list response from `service.get_record_list`.""" total_area: int | None = None - total_time: int | None = None + total_time: int | None = None # stored in seconds total_count: int | None = None record_list: list[CleanRecordListItem] = field(default_factory=list) + @property + def square_meters_area_cleaned(self) -> float | None: + """Returns the area cleaned in square meters.""" + if self.total_area is not None: + return self.total_area / 100 + return None + @dataclass class CleanRecordSummary(RoborockBase): diff --git a/roborock/devices/traits/b01/q7/clean_summary.py b/roborock/devices/traits/b01/q7/clean_summary.py index 0345d41d..4b116c20 100644 --- a/roborock/devices/traits/b01/q7/clean_summary.py +++ b/roborock/devices/traits/b01/q7/clean_summary.py @@ -35,16 +35,16 @@ def __init__(self, channel: MqttChannel) -> None: async def refresh(self) -> None: """Refresh totals and last record detail from the device.""" - record_list = await self.get_record_list() + record_list = await self._get_record_list() self.total_time = record_list.total_time self.total_area = record_list.total_area self.total_count = record_list.total_count - details = await self.get_clean_record_details(record_list=record_list) + details = await self._get_clean_record_details(record_list=record_list) self.last_record_detail = details[0] if details else None - async def get_record_list(self) -> CleanRecordList: + async def _get_record_list(self) -> CleanRecordList: """Fetch the raw device clean record list (`service.get_record_list`).""" result = await send_decoded_command( self._channel, @@ -55,33 +55,21 @@ async def get_record_list(self) -> CleanRecordList: raise TypeError(f"Unexpected response type for GET_RECORD_LIST: {type(result).__name__}: {result!r}") return CleanRecordList.from_dict(result) - @staticmethod - def _parse_record_detail(detail: dict | str | None) -> CleanRecordDetail | None: - if detail is None: - return None - if isinstance(detail, str): - try: - parsed = json.loads(detail) - except json.JSONDecodeError as ex: - raise RoborockException(f"Invalid B01 record detail JSON: {detail!r}") from ex - if not isinstance(parsed, dict): - raise RoborockException(f"Unexpected B01 record detail type: {type(parsed).__name__}: {parsed!r}") - return CleanRecordDetail.from_dict(parsed) - if isinstance(detail, dict): - return CleanRecordDetail.from_dict(detail) - raise RoborockException(f"Unexpected B01 record detail type: {type(detail).__name__}: {detail!r}") - - async def get_clean_record_details(self, *, record_list: CleanRecordList | None = None) -> list[CleanRecordDetail]: + async def _get_clean_record_details(self, *, record_list: CleanRecordList) -> list[CleanRecordDetail]: """Return parsed record detail objects (newest-first).""" - if record_list is None: - record_list = await self.get_record_list() - details: list[CleanRecordDetail] = [] for item in record_list.record_list: - parsed = self._parse_record_detail(item.detail) + if item.detail is None: + continue + try: + parsed = json.loads(item.detail) + except json.JSONDecodeError as ex: + raise RoborockException(f"Invalid B01 record detail JSON: {item.detail!r}") from ex + parsed = CleanRecordDetail.from_dict(parsed) + if parsed is not None: details.append(parsed) - # The app returns the newest record at the end of record_list; reverse so newest is first (index 0). + # The server returns the newest record at the end of record_list; reverse so newest is first (index 0). details.reverse() return details diff --git a/tests/data/b01_q7/test_b01_q7_containers.py b/tests/data/b01_q7/test_b01_q7_containers.py index 768273cb..9d99eb79 100644 --- a/tests/data/b01_q7/test_b01_q7_containers.py +++ b/tests/data/b01_q7/test_b01_q7_containers.py @@ -10,7 +10,6 @@ SCWindMapping, WorkStatusMapping, ) -from roborock.devices.traits.b01.q7.clean_summary import CleanSummaryTrait def test_b01props_deserialization(): @@ -141,8 +140,15 @@ def test_b01_q7_clean_record_list_parses_detail_fields(): assert isinstance(parsed, CleanRecordList) assert parsed.record_list[0].url == "/userdata/record_map/1766368207_1766368283_0_clean_map.bin" - detail = CleanSummaryTrait._parse_record_detail(parsed.record_list[0].detail) + detail_dict = json.loads(parsed.record_list[0].detail or "{}") + detail = CleanRecordDetail.from_dict(detail_dict) assert isinstance(detail, CleanRecordDetail) + assert detail.record_start_time == 1766368207 + assert detail.record_use_time == 60 + assert detail.record_clean_area == 85 + assert detail.record_clean_mode == 0 + assert detail.record_task_status == 20 + assert detail.record_map_url == "/userdata/record_map/1766368207_1766368283_0_clean_map.bin" assert detail.method == 0 assert detail.clean_count == 1 assert detail.record_clean_way == 0 diff --git a/tests/devices/traits/b01/q7/__init__.py b/tests/devices/traits/b01/q7/__init__.py index e69de29b..d79a65d7 100644 --- a/tests/devices/traits/b01/q7/__init__.py +++ b/tests/devices/traits/b01/q7/__init__.py @@ -0,0 +1,41 @@ +import json +from typing import Any + +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad + +from roborock.devices.traits.b01.q7 import Q7PropertiesApi +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol +from tests.fixtures.channel_fixtures import FakeChannel + + +class B01MessageBuilder: + """Helper class to build B01 RPC response messages for tests.""" + + def __init__(self) -> None: + self.msg_id = 123456789 + self.seq = 2020 + + def build(self, data: dict[str, Any] | str, code: int | None = None) -> RoborockMessage: + """Build an encoded B01 RPC response message.""" + message: dict[str, Any] = { + "msgId": str(self.msg_id), + "data": data, + } + if code is not None: + message["code"] = code + return self._build_dps(message) + + def _build_dps(self, message: dict[str, Any] | str) -> RoborockMessage: + """Build an encoded B01 RPC response message.""" + dps_payload = {"dps": {"10000": json.dumps(message)}} + self.seq += 1 + return RoborockMessage( + protocol=RoborockMessageProtocol.RPC_RESPONSE, + payload=pad( + json.dumps(dps_payload).encode(), + AES.block_size, + ), + version=b"B01", + seq=self.seq, + ) diff --git a/tests/devices/traits/b01/q7/conftest.py b/tests/devices/traits/b01/q7/conftest.py new file mode 100644 index 00000000..5dc476f6 --- /dev/null +++ b/tests/devices/traits/b01/q7/conftest.py @@ -0,0 +1,42 @@ +import math +import time +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from roborock.devices.traits.b01.q7 import Q7PropertiesApi +from tests.fixtures.channel_fixtures import FakeChannel + +from . import B01MessageBuilder + + +@pytest.fixture(name="fake_channel") +def fake_channel_fixture() -> FakeChannel: + return FakeChannel() + + +@pytest.fixture(name="q7_api") +def q7_api_fixture(fake_channel: FakeChannel) -> Q7PropertiesApi: + return Q7PropertiesApi(fake_channel) # type: ignore[arg-type] + + +@pytest.fixture(name="expected_msg_id", autouse=True) +def next_message_id_fixture() -> Generator[int, None, None]: + """Fixture to patch get_next_int to return the expected message ID. + + We pick an arbitrary number, but just need it to ensure we can craft a fake + response with the message id matched to the outgoing RPC. + """ + expected_msg_id = math.floor(time.time()) + + # Patch get_next_int to return our expected msg_id so the channel waits for it + with patch("roborock.protocols.b01_q7_protocol.get_next_int", return_value=expected_msg_id): + yield expected_msg_id + + +@pytest.fixture(name="message_builder") +def message_builder_fixture(expected_msg_id: int) -> B01MessageBuilder: + builder = B01MessageBuilder() + builder.msg_id = expected_msg_id + return builder diff --git a/tests/devices/traits/b01/q7/test_clean_summary.py b/tests/devices/traits/b01/q7/test_clean_summary.py new file mode 100644 index 00000000..d82b561f --- /dev/null +++ b/tests/devices/traits/b01/q7/test_clean_summary.py @@ -0,0 +1,176 @@ +"""Tests for CleanSummaryTrait class for B01/Q7 devices.""" + +import json + +import pytest + +from roborock.data.b01_q7 import CleanRecordList +from roborock.devices.traits.b01.q7.clean_summary import CleanSummaryTrait +from roborock.exceptions import RoborockException +from tests.fixtures.channel_fixtures import FakeChannel + +from . import B01MessageBuilder + +CLEAN_RECORD_LIST_DATA = { + "total_time": 34980, + "total_area": 28540, + "total_count": 2, + "record_list": [ + { + "url": "/userdata/record_map/1766368207_1766368283_0_clean_map.bin", + "detail": json.dumps( + { + "record_start_time": 1766368207, + "method": 0, + "record_use_time": 60, + "clean_count": 1, + "record_clean_area": 85, + "record_clean_mode": 0, + "record_clean_way": 0, + "record_task_status": 20, + "record_faultcode": 0, + "record_dust_num": 0, + "clean_current_map": 0, + "record_map_url": "/userdata/record_map/1766368207_1766368283_0_clean_map.bin", + } + ), + }, + { + "url": "/userdata/record_map/1766369000_1766369200_0_clean_map.bin", + "detail": json.dumps( + { + "record_start_time": 1766369000, + "method": 1, + "record_use_time": 120, + "clean_count": 1, + "record_clean_area": 150, + "record_clean_mode": 1, + "record_clean_way": 0, + "record_task_status": 20, + "record_faultcode": 0, + "record_dust_num": 1, + "clean_current_map": 1, + "record_map_url": "/userdata/record_map/1766369000_1766369200_0_clean_map.bin", + } + ), + }, + ], +} + + +@pytest.fixture(name="clean_summary_trait") +def clean_summary_trait_fixture(fake_channel: FakeChannel) -> CleanSummaryTrait: + return CleanSummaryTrait(fake_channel) # type: ignore[arg-type] + + +async def test_refresh_success( + clean_summary_trait: CleanSummaryTrait, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, +) -> None: + """Test successfully refreshing clean summary.""" + fake_channel.response_queue.append(message_builder.build(CLEAN_RECORD_LIST_DATA)) + await clean_summary_trait.refresh() + + assert clean_summary_trait.total_time == 34980 + assert clean_summary_trait.total_area == 28540 + assert clean_summary_trait.total_count == 2 + assert clean_summary_trait.last_record_detail is not None + assert clean_summary_trait.last_record_detail.record_start_time == 1766369000 + + +async def test_refresh_with_no_records( + clean_summary_trait: CleanSummaryTrait, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, +) -> None: + """Test refreshing with no records.""" + empty_response = { + "total_time": 0, + "total_area": 0, + "total_count": 0, + "record_list": [], + } + fake_channel.response_queue.append(message_builder.build(empty_response)) + await clean_summary_trait.refresh() + + assert clean_summary_trait.total_time == 0 + assert clean_summary_trait.total_area == 0 + assert clean_summary_trait.total_count == 0 + assert clean_summary_trait.last_record_detail is None + + +async def test_refresh_propagates_exceptions( + clean_summary_trait: CleanSummaryTrait, + fake_channel: FakeChannel, +) -> None: + """Test that exceptions from channel are propagated during refresh.""" + fake_channel.publish_side_effect = RoborockException("Communication error") + + with pytest.raises(RoborockException, match="Communication error"): + await clean_summary_trait.refresh() + + +async def test_get_clean_record_details_with_none_detail( + clean_summary_trait: CleanSummaryTrait, +) -> None: + """Test getting clean record details when some items have None detail.""" + response_with_none = { + "total_time": 34980, + "total_area": 28540, + "total_count": 2, + "record_list": [ + { + "url": "/userdata/record_map/record1.bin", + "detail": json.dumps( + { + "record_start_time": 1766368207, + "method": 0, + "record_use_time": 60, + "clean_count": 1, + "record_clean_area": 85, + "record_clean_mode": 0, + "record_clean_way": 0, + "record_task_status": 20, + "record_faultcode": 0, + "record_dust_num": 0, + "clean_current_map": 0, + "record_map_url": "/userdata/record_map/record1.bin", + } + ), + }, + { + "url": "/userdata/record_map/record2.bin", + "detail": None, + }, + ], + } + + details = await clean_summary_trait._get_clean_record_details( + record_list=CleanRecordList.from_dict(response_with_none) + ) + + assert len(details) == 1 + assert details[0].record_start_time == 1766368207 + + +async def test_get_clean_record_details_invalid_json( + clean_summary_trait: CleanSummaryTrait, +) -> None: + """Test that invalid JSON in detail raises RoborockException.""" + response_with_invalid_json = { + "total_time": 34980, + "total_area": 28540, + "total_count": 1, + "record_list": [ + { + "url": "/userdata/record_map/record1.bin", + "detail": "invalid json{", + }, + ], + } + + with pytest.raises(RoborockException, match="Invalid B01 record detail JSON"): + await clean_summary_trait._get_clean_record_details( + record_list=CleanRecordList.from_dict(response_with_invalid_json) + ) diff --git a/tests/devices/traits/b01/q7/test_init.py b/tests/devices/traits/b01/q7/test_init.py index ab875005..c1aafe21 100644 --- a/tests/devices/traits/b01/q7/test_init.py +++ b/tests/devices/traits/b01/q7/test_init.py @@ -1,13 +1,9 @@ import json -import math -import time -from collections.abc import Generator from typing import Any -from unittest.mock import patch import pytest from Crypto.Cipher import AES -from Crypto.Util.Padding import pad, unpad +from Crypto.Util.Padding import unpad from roborock.data.b01_q7 import ( CleanTaskTypeMapping, @@ -20,71 +16,10 @@ from roborock.devices.traits.b01.q7 import Q7PropertiesApi from roborock.exceptions import RoborockException from roborock.protocols.b01_q7_protocol import B01_VERSION, Q7RequestMessage -from roborock.roborock_message import RoborockB01Props, RoborockMessage, RoborockMessageProtocol +from roborock.roborock_message import RoborockB01Props, RoborockMessageProtocol from tests.fixtures.channel_fixtures import FakeChannel - -class B01MessageBuilder: - """Helper class to build B01 RPC response messages for tests.""" - - def __init__(self) -> None: - self.msg_id = 123456789 - self.seq = 2020 - - def build(self, data: dict[str, Any] | str, code: int | None = None) -> RoborockMessage: - """Build an encoded B01 RPC response message.""" - message: dict[str, Any] = { - "msgId": str(self.msg_id), - "data": data, - } - if code is not None: - message["code"] = code - return self._build_dps(message) - - def _build_dps(self, message: dict[str, Any] | str) -> RoborockMessage: - """Build an encoded B01 RPC response message.""" - dps_payload = {"dps": {"10000": json.dumps(message)}} - self.seq += 1 - return RoborockMessage( - protocol=RoborockMessageProtocol.RPC_RESPONSE, - payload=pad( - json.dumps(dps_payload).encode(), - AES.block_size, - ), - version=b"B01", - seq=self.seq, - ) - - -@pytest.fixture(name="fake_channel") -def fake_channel_fixture() -> FakeChannel: - return FakeChannel() - - -@pytest.fixture(name="q7_api") -def q7_api_fixture(fake_channel: FakeChannel) -> Q7PropertiesApi: - return Q7PropertiesApi(fake_channel) # type: ignore[arg-type] - - -@pytest.fixture(name="expected_msg_id", autouse=True) -def next_message_id_fixture() -> Generator[int, None, None]: - """Fixture to patch get_next_int to return the expected message ID. - - We pick an arbitrary number, but just need it to ensure we can craft a fake - response with the message id matched to the outgoing RPC. - """ - expected_msg_id = math.floor(time.time()) - - # Patch get_next_int to return our expected msg_id so the channel waits for it - with patch("roborock.protocols.b01_q7_protocol.get_next_int", return_value=expected_msg_id): - yield expected_msg_id - - -@pytest.fixture(name="message_builder") -def message_builder_fixture(expected_msg_id: int) -> B01MessageBuilder: - builder = B01MessageBuilder() - builder.msg_id = expected_msg_id - return builder +from . import B01MessageBuilder async def test_q7_api_query_values( diff --git a/tests/mock_data.py b/tests/mock_data.py index 7e517691..3af80582 100644 --- a/tests/mock_data.py +++ b/tests/mock_data.py @@ -124,8 +124,8 @@ TESTDATA = pathlib.Path("tests/testdata") -PRODUCTS = {file.name: json.load(file.open()) for file in TESTDATA.glob("home_data_product_*.json")} -DEVICES = {file.name: json.load(file.open()) for file in TESTDATA.glob("home_data_device_*.json")} +PRODUCTS = {file.name: json.load(file.open(encoding="utf-8")) for file in TESTDATA.glob("home_data_product_*.json")} +DEVICES = {file.name: json.load(file.open(encoding="utf-8")) for file in TESTDATA.glob("home_data_device_*.json")} # Products A27_PRODUCT_DATA = PRODUCTS["home_data_product_a27.json"] From 1519e9b0b23a50406f1294af93bca30e7f845fb1 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 1 Feb 2026 15:55:06 -0500 Subject: [PATCH 4/4] chore: move parsing to the container --- roborock/data/b01_q7/b01_q7_containers.py | 14 ++++++++++++++ roborock/devices/traits/b01/q7/clean_summary.py | 16 ++++++++-------- .../devices/traits/b01/q7/test_clean_summary.py | 11 ++++++++--- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/roborock/data/b01_q7/b01_q7_containers.py b/roborock/data/b01_q7/b01_q7_containers.py index c55aec19..f3026d55 100644 --- a/roborock/data/b01_q7/b01_q7_containers.py +++ b/roborock/data/b01_q7/b01_q7_containers.py @@ -1,6 +1,9 @@ import datetime +import json from dataclasses import dataclass, field +from functools import cached_property +from ...exceptions import RoborockException from ..containers import RoborockBase from .b01_q7_code_mappings import ( B01Fault, @@ -246,6 +249,17 @@ class CleanRecordListItem(RoborockBase): url: str | None = None detail: str | None = None + @cached_property + def detail_parsed(self) -> CleanRecordDetail | None: + """Parse and return the detail as a CleanRecordDetail object.""" + if self.detail is None: + return None + try: + parsed = json.loads(self.detail) + except json.JSONDecodeError as ex: + raise RoborockException(f"Invalid B01 record detail JSON: {self.detail!r}") from ex + return CleanRecordDetail.from_dict(parsed) + @dataclass class CleanRecordList(RoborockBase): diff --git a/roborock/devices/traits/b01/q7/clean_summary.py b/roborock/devices/traits/b01/q7/clean_summary.py index 4b116c20..4ca3267a 100644 --- a/roborock/devices/traits/b01/q7/clean_summary.py +++ b/roborock/devices/traits/b01/q7/clean_summary.py @@ -6,7 +6,7 @@ from __future__ import annotations -import json +import logging from roborock import CleanRecordDetail, CleanRecordList, CleanRecordSummary from roborock.devices.rpc.b01_q7_channel import send_decoded_command @@ -20,6 +20,8 @@ "CleanSummaryTrait", ] +_LOGGER = logging.getLogger(__name__) + class CleanSummaryTrait(CleanRecordSummary, Trait): """B01/Q7 clean summary + clean record access (via record list service).""" @@ -59,14 +61,12 @@ async def _get_clean_record_details(self, *, record_list: CleanRecordList) -> li """Return parsed record detail objects (newest-first).""" details: list[CleanRecordDetail] = [] for item in record_list.record_list: - if item.detail is None: - continue try: - parsed = json.loads(item.detail) - except json.JSONDecodeError as ex: - raise RoborockException(f"Invalid B01 record detail JSON: {item.detail!r}") from ex - parsed = CleanRecordDetail.from_dict(parsed) - + parsed = item.detail_parsed + except RoborockException as ex: + # Rather than failing if something goes wrong here, we should fail and log to tell the user. + _LOGGER.debug("Failed to parse record detail: %s", ex) + continue if parsed is not None: details.append(parsed) diff --git a/tests/devices/traits/b01/q7/test_clean_summary.py b/tests/devices/traits/b01/q7/test_clean_summary.py index d82b561f..0a2bccbb 100644 --- a/tests/devices/traits/b01/q7/test_clean_summary.py +++ b/tests/devices/traits/b01/q7/test_clean_summary.py @@ -1,6 +1,7 @@ """Tests for CleanSummaryTrait class for B01/Q7 devices.""" import json +import logging import pytest @@ -156,8 +157,9 @@ async def test_get_clean_record_details_with_none_detail( async def test_get_clean_record_details_invalid_json( clean_summary_trait: CleanSummaryTrait, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test that invalid JSON in detail raises RoborockException.""" + """Test that invalid JSON in detail is logged and skipped.""" response_with_invalid_json = { "total_time": 34980, "total_area": 28540, @@ -170,7 +172,10 @@ async def test_get_clean_record_details_invalid_json( ], } - with pytest.raises(RoborockException, match="Invalid B01 record detail JSON"): - await clean_summary_trait._get_clean_record_details( + with caplog.at_level(logging.DEBUG): + details = await clean_summary_trait._get_clean_record_details( record_list=CleanRecordList.from_dict(response_with_invalid_json) ) + + assert len(details) == 0 + assert any("Failed to parse record detail" in record.message for record in caplog.records)