diff --git a/roborock/data/b01_q7/b01_q7_containers.py b/roborock/data/b01_q7/b01_q7_containers.py index a9b8392d..f3026d55 100644 --- a/roborock/data/b01_q7/b01_q7_containers.py +++ b/roborock/data/b01_q7/b01_q7_containers.py @@ -1,5 +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, @@ -203,3 +207,82 @@ 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 + # 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 + 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 + + @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 | 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): + """Represents the clean record list response from `service.get_record_list`.""" + + total_area: 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): + """Represents clean record totals for B01/Q7 devices.""" + + 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/__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..4ca3267a --- /dev/null +++ b/roborock/devices/traits/b01/q7/clean_summary.py @@ -0,0 +1,75 @@ +"""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 logging + +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", +] + +_LOGGER = logging.getLogger(__name__) + + +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 + + 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) + + async def _get_clean_record_details(self, *, record_list: CleanRecordList) -> list[CleanRecordDetail]: + """Return parsed record detail objects (newest-first).""" + details: list[CleanRecordDetail] = [] + for item in record_list.record_list: + try: + 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) + + # 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 de5b108f..9d99eb79 100644 --- a/tests/data/b01_q7/test_b01_q7_containers.py +++ b/tests/data/b01_q7/test_b01_q7_containers.py @@ -1,8 +1,12 @@ """Test cases for the containers module.""" +import json + from roborock.data.b01_q7 import ( B01Fault, B01Props, + CleanRecordDetail, + CleanRecordList, SCWindMapping, WorkStatusMapping, ) @@ -102,3 +106,52 @@ 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_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 + assert detail.record_faultcode == 0 + assert detail.record_dust_num == 0 + assert detail.clean_current_map == 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..0a2bccbb --- /dev/null +++ b/tests/devices/traits/b01/q7/test_clean_summary.py @@ -0,0 +1,181 @@ +"""Tests for CleanSummaryTrait class for B01/Q7 devices.""" + +import json +import logging + +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, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that invalid JSON in detail is logged and skipped.""" + 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 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) 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"]