Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions roborock/data/b01_q7/b01_q7_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment thread
Lash-L marked this conversation as resolved.

Comment thread
Lash-L marked this conversation as resolved.
@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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This really can be either type?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally thought i saw data where it was not a str in the app code, but I looked again and I think it is always a json str.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So it's always a json dict? So then this should be dict (post json parsing)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is a dict after we parse from json - but not at the time of accessing/loading from result of api.

it is a dumped json dict within the main json dict, so it is not parsed.

We have to manually parse it before access. We don't update the value either after we parse - i guess we theoretically could?

CleanRecordList.from_dict(result) has detail as a str

then we access .detail -> parse it to a dict -> convert it to CleanRecordDetail

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should use the same field for the string or dict value and expose it in the API. Maybe there could be a manual step to "clean" the data first, converting it from json to dict, then parse it? or something else.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a local change i'm trying to finish up - but i changed the typing to be just str. You can let me know if that's what you were thinking



@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
Comment thread
Lash-L marked this conversation as resolved.
Outdated
total_count: int | None = None
last_record_detail: CleanRecordDetail | None = None
Comment thread
Lash-L marked this conversation as resolved.
7 changes: 7 additions & 0 deletions roborock/devices/traits/b01/q7/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
82 changes: 82 additions & 0 deletions roborock/devices/traits/b01/q7/clean_summary.py
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
Lash-L marked this conversation as resolved.
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:
Comment thread
Lash-L marked this conversation as resolved.
Outdated
"""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}")
Comment thread
Lash-L marked this conversation as resolved.
Outdated

async def get_clean_record_details(self, *, record_list: CleanRecordList | None = None) -> list[CleanRecordDetail]:
Comment thread
Lash-L marked this conversation as resolved.
Outdated
"""Return parsed record detail objects (newest-first)."""
if record_list is None:
Comment thread
Lash-L marked this conversation as resolved.
Outdated
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
Comment thread
Lash-L marked this conversation as resolved.
Outdated
details.reverse()
return details
Comment thread
Lash-L marked this conversation as resolved.
47 changes: 47 additions & 0 deletions tests/data/b01_q7/test_b01_q7_containers.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test appears to be missing assertions for some of the parsed detail fields. The test should verify record_start_time, record_use_time, record_clean_area, record_clean_mode, record_task_status, and record_map_url to ensure comprehensive coverage of the CleanRecordDetail parsing functionality.

Copilot uses AI. Check for mistakes.
Loading