Skip to content

Commit 48b7fcf

Browse files
committed
Merge branches 'feat/q10-cli-commands' and 'feat/q10-cli-commands' of https://github.com/lboue/python-roborock into feat/q10-cli-commands
# Conflicts: # CHANGELOG.md # roborock/cli.py # roborock/data/b01_q10/b01_q10_code_mappings.py
2 parents 31b2328 + e24004e commit 48b7fcf

File tree

17 files changed

+858
-86
lines changed

17 files changed

+858
-86
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
- Add Q10 vacuum CLI commands
1616
([`6122647`](https://github.com/Python-roborock/python-roborock/commit/6122647a0e5e5b5c5e5e5e5e5e5e5e5e5e5e5e5e))
1717

18+
- Add clean record for Q7 ([#745](https://github.com/Python-roborock/python-roborock/pull/745),
19+
[`329e52b`](https://github.com/Python-roborock/python-roborock/commit/329e52bc34b1a5de2685b94002deae025eb0bd1c))
20+
1821
### Bug Fixes
1922

2023
- Register Q10 commands in session shell

roborock/cli.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from roborock.devices.device import RoborockDevice
5151
from roborock.devices.device_manager import DeviceManager, UserParams, create_device_manager
5252
from roborock.devices.traits import Trait
53+
from roborock.devices.traits.b01.q10.vacuum import VacuumTrait
5354
from roborock.devices.traits.v1 import V1TraitMixin
5455
from roborock.devices.traits.v1.consumeable import ConsumableAttribute
5556
from roborock.devices.traits.v1.map_content import MapContentTrait
@@ -438,7 +439,7 @@ async def _display_v1_trait(context: RoborockContext, device_id: str, display_fu
438439
click.echo(dump_json(trait.as_dict()))
439440

440441

441-
async def _q10_vacuum_trait(context: RoborockContext, device_id: str):
442+
async def _q10_vacuum_trait(context: RoborockContext, device_id: str) -> VacuumTrait:
442443
"""Get VacuumTrait from Q10 device."""
443444
device_manager = await context.get_device_manager()
444445
device = await device_manager.get_device(device_id)
@@ -1150,11 +1151,11 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur
11501151
click.echo("Done.")
11511152

11521153

1153-
@click.command()
1154+
@session.command()
11541155
@click.option("--device_id", required=True, help="Device ID")
11551156
@click.pass_context
11561157
@async_command
1157-
async def q10_vacuum_start(ctx, device_id):
1158+
async def q10_vacuum_start(ctx: click.Context, device_id: str) -> None:
11581159
"""Start vacuum cleaning on Q10 device."""
11591160
context: RoborockContext = ctx.obj
11601161
try:
@@ -1167,11 +1168,11 @@ async def q10_vacuum_start(ctx, device_id):
11671168
click.echo(f"Error: {e}")
11681169

11691170

1170-
@click.command()
1171+
@session.command()
11711172
@click.option("--device_id", required=True, help="Device ID")
11721173
@click.pass_context
11731174
@async_command
1174-
async def q10_vacuum_pause(ctx, device_id):
1175+
async def q10_vacuum_pause(ctx: click.Context, device_id: str) -> None:
11751176
"""Pause vacuum cleaning on Q10 device."""
11761177
context: RoborockContext = ctx.obj
11771178
try:
@@ -1184,11 +1185,11 @@ async def q10_vacuum_pause(ctx, device_id):
11841185
click.echo(f"Error: {e}")
11851186

11861187

1187-
@click.command()
1188+
@session.command()
11881189
@click.option("--device_id", required=True, help="Device ID")
11891190
@click.pass_context
11901191
@async_command
1191-
async def q10_vacuum_resume(ctx, device_id):
1192+
async def q10_vacuum_resume(ctx: click.Context, device_id: str) -> None:
11921193
"""Resume vacuum cleaning on Q10 device."""
11931194
context: RoborockContext = ctx.obj
11941195
try:
@@ -1201,11 +1202,11 @@ async def q10_vacuum_resume(ctx, device_id):
12011202
click.echo(f"Error: {e}")
12021203

12031204

1204-
@click.command()
1205+
@session.command()
12051206
@click.option("--device_id", required=True, help="Device ID")
12061207
@click.pass_context
12071208
@async_command
1208-
async def q10_vacuum_stop(ctx, device_id):
1209+
async def q10_vacuum_stop(ctx: click.Context, device_id: str) -> None:
12091210
"""Stop vacuum cleaning on Q10 device."""
12101211
context: RoborockContext = ctx.obj
12111212
try:
@@ -1218,11 +1219,11 @@ async def q10_vacuum_stop(ctx, device_id):
12181219
click.echo(f"Error: {e}")
12191220

12201221

1221-
@click.command()
1222+
@session.command()
12221223
@click.option("--device_id", required=True, help="Device ID")
12231224
@click.pass_context
12241225
@async_command
1225-
async def q10_vacuum_dock(ctx, device_id):
1226+
async def q10_vacuum_dock(ctx: click.Context, device_id: str) -> None:
12261227
"""Return vacuum to dock on Q10 device."""
12271228
context: RoborockContext = ctx.obj
12281229
try:

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/data/code_mappings.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ def from_code(cls, code: int) -> Self:
7777
raise ValueError(message)
7878

7979
@classmethod
80-
def from_code_optional(cls, code: int) -> RoborockModeEnum | None:
80+
def from_code_optional(cls, code: int) -> Self | None:
81+
"""Gracefully return None if the code does not exist."""
8182
try:
8283
return cls.from_code(code)
8384
except ValueError:

roborock/devices/traits/b01/q10/vacuum.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Traits for Q10 B01 devices."""
22

3-
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
3+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXCleanType
44

55
from .command import CommandTrait
66

@@ -54,3 +54,17 @@ async def return_to_dock(self) -> None:
5454
command=B01_Q10_DP.START_DOCK_TASK,
5555
params={},
5656
)
57+
58+
async def empty_dustbin(self) -> None:
59+
"""Empty the dustbin at the dock."""
60+
await self._command.send(
61+
command=B01_Q10_DP.START_DOCK_TASK,
62+
params=2,
63+
)
64+
65+
async def set_clean_mode(self, mode: YXCleanType) -> None:
66+
"""Set the cleaning mode (vacuum, mop, or both)."""
67+
await self._command.send(
68+
command=B01_Q10_DP.CLEAN_MODE,
69+
params=mode.code,
70+
)

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

roborock/protocols/b01_q10_protocol.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,7 @@ def _convert_datapoints(datapoints: dict[str, Any], message: RoborockMessage) ->
4545
except ValueError as e:
4646
raise ValueError(f"dps key is not a valid integer: {e} for {message.payload!r}") from e
4747
if (dps := B01_Q10_DP.from_code_optional(code)) is not None:
48-
# Update from_code to use `Self` on newer python version to remove this type ignore
49-
result[dps] = value # type: ignore[index]
48+
result[dps] = value
5049
return result
5150

5251

0 commit comments

Comments
 (0)