Skip to content

Commit 79a996d

Browse files
andrewlyeatsAndrew Yeatsallenporter
authored
feat: Q10 (B01/ss07) clean-record history trait (#857)
* feat: add Q10 (B01/ss07) clean-record history trait Q10 clean history is push-driven over dpCleanRecord (DP 52), unlike the Q7's synchronous get_record_list RPC. Adds: - Q10CleanRecord: parses the 12-field op:list underscore string (raw retained), with crash-safe enum accessors .scope/.work/.result/.started_by (IntEnums that return None for an unmapped code, never raising like the YX from_code path) - CleanHistoryTrait: refresh() sends {op:list}; update_from_dps decodes both the full list and the single op:notify clean-finished push (upsert by record_id, newest-first); registered as a read-model trait in the dispatch loop Field layout is the device app's own (setHoldData), cross-confirmed by ioBroker's Q10CleanRecordService. Tests cover decode, enum labels + unmapped-safety, and the list/notify paths. * refactor: widen parse_record's parameter type to Any | None Co-authored-by: Allen Porter <allen.porter@gmail.com> * refactor: call the static parse_record via the class, not self parse_record is a @staticmethod, so call it as CleanRecordConverter.parse_record(...) rather than self.parse_record(...). --------- Co-authored-by: Andrew Yeats <ayeats@users-MacBook-Air.local> Co-authored-by: Allen Porter <allen.porter@gmail.com>
1 parent ffb53a9 commit 79a996d

8 files changed

Lines changed: 476 additions & 0 deletions

File tree

roborock/data/b01_q10/b01_q10_code_mappings.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,43 @@ class YXDeviceCleanTask(RoborockModeEnum):
226226
PART = "part", 5
227227

228228

229+
class YXCleanScope(RoborockModeEnum):
230+
"""Clean scope/type as stored in a *clean record* (``dpCleanRecord``, field 7).
231+
232+
This is the same conceptual axis as the live :class:`YXDeviceCleanTask`, but the
233+
persisted record uses a different integer encoding -- e.g. a full clean records
234+
``0`` here vs ``1`` (``smart``) live, and a select-rooms clean records ``1`` here
235+
vs ``2`` (``electoral``) live. Ground-truthed against the app's History labels;
236+
code ``2`` was never observed on ss07 and is intentionally unmapped (so it
237+
resolves to ``None`` rather than a guessed label).
238+
"""
239+
240+
UNKNOWN = "unknown", -1
241+
FULL = "full", 0
242+
SELECTIVE_ROOM = "selective_room", 1
243+
ZONE = "zone", 3
244+
SPOT = "spot", 4
245+
246+
247+
class YXCleaningResult(RoborockModeEnum):
248+
"""How a clean ended, as stored in a clean record (``dpCleanRecord``, field 9)."""
249+
250+
UNKNOWN = "unknown", -1
251+
INTERRUPTED = "interrupted", 0 # ended on a fault
252+
COMPLETED = "completed", 1
253+
STOPPED = "stopped", 2 # ended early without a fault
254+
255+
256+
class YXStartMethod(RoborockModeEnum):
257+
"""What initiated a clean, as stored in a clean record (``dpCleanRecord``, field 10)."""
258+
259+
UNKNOWN = "unknown", -1
260+
REMOTE = "remote", 0
261+
APP = "app", 1
262+
TIMER = "timer", 2 # schedule / timer
263+
BUTTON = "button", 3 # device button
264+
265+
229266
class YXDeviceDustCollectionFrequency(RoborockModeEnum):
230267
# The app exposes "regular" (code 0) vs "frequent", where "frequent" selects
231268
# one of the every-N-cleans intervals below.

roborock/data/b01_q10/b01_q10_containers.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
automatically update objects from raw device responses.
77
"""
88

9+
import datetime
910
from dataclasses import dataclass, field
1011

1112
from ..containers import RoborockBase
@@ -14,12 +15,15 @@
1415
YXAreaUnit,
1516
YXBackType,
1617
YXCarpetCleanType,
18+
YXCleaningResult,
1719
YXCleanLine,
20+
YXCleanScope,
1821
YXCleanType,
1922
YXDeviceCleanTask,
2023
YXDeviceDustCollectionFrequency,
2124
YXDeviceState,
2225
YXFanLevel,
26+
YXStartMethod,
2327
YXWaterLevel,
2428
)
2529

@@ -32,6 +36,52 @@ class dpCleanRecord(RoborockBase):
3236
data: list
3337

3438

39+
@dataclass
40+
class Q10CleanRecord(RoborockBase):
41+
"""A single Q10 (ss07) clean record decoded from a ``dpCleanRecord`` (DP 52) entry.
42+
43+
The device returns each record as a 12-field underscore-delimited string in the
44+
``data`` list of a ``{"op": "list"}`` query (or the ``id`` of an ``{"op": "notify"}``
45+
push). The ``*_len`` values are internal blob-length metrics whose units aren't
46+
confirmed; the original ``raw`` string is always retained. The enum fields resolve
47+
an unmapped/unset code to ``None`` rather than guessing.
48+
"""
49+
50+
raw: str
51+
record_id: str | None = None
52+
start_time: int | None = None
53+
"""Clean start time, Unix seconds."""
54+
clean_time: int | None = None
55+
"""Cleaning time, minutes."""
56+
clean_area: int | None = None
57+
"""Cleaned area in square meters."""
58+
map_len: int | None = None
59+
"""Length of the saved map blob for this record (0 = none stored)."""
60+
path_len: int | None = None
61+
"""Length of the saved path blob for this record (0 = none stored)."""
62+
virtual_len: int | None = None
63+
"""Length of the saved virtual-restriction blob for this record (0 = none stored)."""
64+
clean_mode: YXCleanScope | None = None
65+
"""Clean scope/type (full / selective-room / zone / spot). Same axis as the live
66+
:class:`YXDeviceCleanTask` but a different record encoding -- see :class:`YXCleanScope`."""
67+
work_mode: YXCleanType | None = None
68+
"""Actual work performed (vac+mop / vacuum / mop) -- the same enum :class:`Q10Status`
69+
uses for the live clean-mode DP. Records only ever carry 1/2/3 here."""
70+
cleaning_result: YXCleaningResult | None = None
71+
"""How the clean ended: 0 interrupted (fault), 1 completed, 2 stopped (no fault)."""
72+
start_method: YXStartMethod | None = None
73+
"""What initiated the clean: 0 remote, 1 app, 2 timer, 3 button."""
74+
collect_dust_count: int | None = None
75+
"""Number of dock auto-empties during the clean."""
76+
77+
@property
78+
def start_datetime(self) -> datetime.datetime | None:
79+
"""The start time as a timezone-aware (UTC) datetime."""
80+
if self.start_time is not None:
81+
return datetime.datetime.fromtimestamp(self.start_time).astimezone(datetime.UTC)
82+
return None
83+
84+
3585
@dataclass
3686
class dpMultiMap(RoborockBase):
3787
op: str

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from .button_light import ButtonLightTrait
1414
from .child_lock import ChildLockTrait
15+
from .clean_history import CleanHistoryTrait
1516
from .command import CommandTrait
1617
from .consumable import ConsumableTrait
1718
from .do_not_disturb import DoNotDisturbTrait
@@ -27,6 +28,7 @@
2728
"Q10PropertiesApi",
2829
"ButtonLightTrait",
2930
"ChildLockTrait",
31+
"CleanHistoryTrait",
3032
"ConsumableTrait",
3133
"DoNotDisturbTrait",
3234
"DustCollectionTrait",
@@ -78,6 +80,9 @@ class Q10PropertiesApi(Trait):
7880
map: MapContentTrait
7981
"""Trait for fetching the current parsed map (image + rooms)."""
8082

83+
clean_history: CleanHistoryTrait
84+
"""Trait for fetching the device clean-record history (``dpCleanRecord``)."""
85+
8186
def __init__(self, channel: MqttChannel) -> None:
8287
"""Initialize the B01Props API."""
8388
self._channel = channel
@@ -93,6 +98,7 @@ def __init__(self, channel: MqttChannel) -> None:
9398
self.network_info = NetworkInfoTrait()
9499
self.consumable = ConsumableTrait()
95100
self.map = MapContentTrait()
101+
self.clean_history = CleanHistoryTrait(self.command)
96102
# Read-model traits updated from the device's DPS push stream.
97103
self._updatable_traits = [
98104
self.status,
@@ -102,6 +108,7 @@ def __init__(self, channel: MqttChannel) -> None:
102108
self.dust_collection,
103109
self.network_info,
104110
self.consumable,
111+
self.clean_history,
105112
]
106113
self._subscribe_task: asyncio.Task[None] | None = None
107114

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Clean history trait for Q10 B01 devices.
2+
3+
Unlike the Q7 (which exposes a synchronous ``service.get_record_list`` RPC), the
4+
Q10 is push-driven: :meth:`CleanHistoryTrait.refresh` sends an ``{"op": "list"}``
5+
query for ``dpCleanRecord`` (DP 52) over the ``dpCommon`` channel, and the device
6+
publishes its clean-record list back on the subscribe stream, which
7+
:meth:`CleanHistoryTrait.update_from_dps` then decodes.
8+
9+
Wire parsing is separated from state management: :class:`CleanRecordConverter` turns
10+
a ``dpCleanRecord`` envelope into a :class:`CleanRecordPush`, and the trait applies it.
11+
"""
12+
13+
import logging
14+
from dataclasses import dataclass, field
15+
from typing import Any
16+
17+
from roborock.data.b01_q10.b01_q10_code_mappings import (
18+
B01_Q10_DP,
19+
YXCleaningResult,
20+
YXCleanScope,
21+
YXCleanType,
22+
YXStartMethod,
23+
)
24+
from roborock.data.b01_q10.b01_q10_containers import Q10CleanRecord
25+
26+
from .command import CommandTrait
27+
from .common import UpdatableTrait
28+
29+
__all__ = [
30+
"CleanHistoryTrait",
31+
"CleanRecordConverter",
32+
"CleanRecordPush",
33+
]
34+
35+
_LOGGER = logging.getLogger(__name__)
36+
37+
_RECORD_FIELD_COUNT = 12
38+
39+
40+
@dataclass
41+
class CleanRecordPush:
42+
"""A parsed ``dpCleanRecord`` push: the records it carries and how to apply them.
43+
44+
``replace`` is ``True`` for an ``{"op": "list"}`` reply (the full history, which
45+
replaces the current state) and ``False`` for an ``{"op": "notify"}`` push (a
46+
single just-finished record, which is upserted).
47+
"""
48+
49+
records: list[Q10CleanRecord] = field(default_factory=list)
50+
replace: bool = False
51+
52+
53+
class CleanRecordConverter:
54+
"""Converts a raw ``dpCleanRecord`` (DP 52) envelope into a :class:`CleanRecordPush`.
55+
56+
Mirrors the converter-per-object pattern used by the other Q10 traits: parsing the
57+
wire payload lives here, separate from the trait that manages the record list.
58+
"""
59+
60+
def parse(self, envelope: dict[str, Any]) -> CleanRecordPush | None:
61+
"""Parse a decoded ``dpCleanRecord`` envelope into a :class:`CleanRecordPush`.
62+
63+
Returns ``None`` for an envelope that carries nothing usable (so the trait can
64+
ignore it without changing state). Malformed individual records are skipped.
65+
"""
66+
if envelope.get("op") == "notify":
67+
record = CleanRecordConverter.parse_record(envelope.get("id"))
68+
return CleanRecordPush([record], replace=False) if record is not None else None
69+
data = envelope.get("data")
70+
if not isinstance(data, list):
71+
return None
72+
records = [record for item in data if (record := CleanRecordConverter.parse_record(item)) is not None]
73+
return CleanRecordPush(records, replace=True)
74+
75+
@staticmethod
76+
def parse_record(raw: Any | None) -> Q10CleanRecord | None:
77+
"""Decode one underscore-delimited clean-record string into a :class:`Q10CleanRecord`.
78+
79+
The device joins 12 values with ``_``: recordId, startTime (Unix s), cleanTime
80+
(min), cleanArea (m2), mapLen, pathLen, virtualLen, cleanMode, workMode,
81+
cleaningResult, startMethod, collectDustCount. Returns ``None`` for anything but
82+
a well-formed 12-field string; an unmapped enum code resolves to ``None`` on its
83+
field (``raw`` keeps the original).
84+
"""
85+
if not isinstance(raw, str):
86+
return None
87+
parts = raw.split("_")
88+
if len(parts) != _RECORD_FIELD_COUNT:
89+
return None
90+
try:
91+
return Q10CleanRecord(
92+
raw=raw,
93+
record_id=parts[0],
94+
start_time=int(parts[1]),
95+
clean_time=int(parts[2]),
96+
clean_area=int(parts[3]),
97+
map_len=int(parts[4]),
98+
path_len=int(parts[5]),
99+
virtual_len=int(parts[6]),
100+
clean_mode=YXCleanScope.from_code_optional(int(parts[7])),
101+
work_mode=YXCleanType.from_code_optional(int(parts[8])),
102+
cleaning_result=YXCleaningResult.from_code_optional(int(parts[9])),
103+
start_method=YXStartMethod.from_code_optional(int(parts[10])),
104+
collect_dust_count=int(parts[11]),
105+
)
106+
except ValueError:
107+
return None
108+
109+
110+
class CleanHistoryTrait(UpdatableTrait):
111+
"""Access to the Q10 clean-record history (``dpCleanRecord``, DP 52).
112+
113+
A read-model trait updated from the DPS stream like the others, but it overrides
114+
:meth:`update_from_dps` because the payload is a structured push (a record list,
115+
or a single ``op:"notify"`` record) rather than a flat data-point-to-field map.
116+
"""
117+
118+
def __init__(self, command: CommandTrait) -> None:
119+
"""Initialize the clean history trait."""
120+
UpdatableTrait.__init__(self, command, _LOGGER)
121+
self._converter = CleanRecordConverter()
122+
self.records: list[Q10CleanRecord] = []
123+
"""Decoded clean records, most recent first."""
124+
125+
@property
126+
def last_record(self) -> Q10CleanRecord | None:
127+
"""The most recent clean record, or ``None`` if there are none."""
128+
return self.records[0] if self.records else None
129+
130+
async def refresh(self) -> None:
131+
"""Request the clean-record list from the device.
132+
133+
This sends the query and returns immediately; the records arrive
134+
asynchronously on the device stream and populate :attr:`records` once
135+
:meth:`update_from_dps` processes the ``dpCleanRecord`` push.
136+
"""
137+
if self._command is None:
138+
raise ValueError("Trait is read-only; no command channel was provided")
139+
await self._command.send(
140+
B01_Q10_DP.COMMON,
141+
params={str(B01_Q10_DP.CLEAN_RECORD.code): {"op": "list"}},
142+
)
143+
144+
def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
145+
"""Apply a ``dpCleanRecord`` push (a full list reply or a single notify)."""
146+
envelope = decoded_dps.get(B01_Q10_DP.CLEAN_RECORD)
147+
if not isinstance(envelope, dict):
148+
return
149+
push = self._converter.parse(envelope)
150+
if push is None:
151+
return
152+
self._apply(push)
153+
154+
def _apply(self, push: CleanRecordPush) -> None:
155+
"""Merge or replace the records from ``push``, then sort newest-first and notify."""
156+
if push.replace:
157+
records = list(push.records)
158+
else:
159+
updated_ids = {record.record_id for record in push.records}
160+
records = [record for record in self.records if record.record_id not in updated_ids]
161+
records.extend(push.records)
162+
records.sort(key=lambda record: record.start_time or 0, reverse=True)
163+
self.records = records
164+
self._notify_update()

0 commit comments

Comments
 (0)