Skip to content

feat: Q10 (B01/ss07) clean-record history trait#857

Open
andrewlyeats wants to merge 1 commit into
Python-roborock:mainfrom
andrewlyeats:feat/q10-clean-history
Open

feat: Q10 (B01/ss07) clean-record history trait#857
andrewlyeats wants to merge 1 commit into
Python-roborock:mainfrom
andrewlyeats:feat/q10-clean-history

Conversation

@andrewlyeats

Copy link
Copy Markdown
Contributor

Implements #767 for B01/ss07 (Q10).

Adds a CleanHistoryTrait that fetches and decodes the Q10's clean-record history. Unlike the Q7 (which exposes a synchronous get_record_list RPC), the Q10 is push-driven, so the trait queries and then decodes the device's reply on the subscribe stream.

What it does

  • refresh() sends {"op": "list"} for dpCleanRecord (DP 52) over dpCommon; the device publishes its record list back, and update_from_dps() decodes each underscore-delimited record into a Q10CleanRecord (12 fields: id, start time, clean time, area, the three blob lengths, clean mode, work mode, result, start method, dust-collection count).
  • op:"notify" — the single clean-finished push the device emits when a clean ends — is decoded the same way and upserted by record id, so the history stays current without re-polling.
  • Friendly enum labels (scope / work / result / started_by) are layered on as crash-safe properties that return None for an unmapped code rather than raising. The raw ints stay authoritative, so the label layer can be kept, renamed, or dropped without touching the data.

Validation

  • Live on a real Q10 (ss07, fw 03.11.24): op:"list" returns and decodes 25 records on the device's own reply topic; the end-of-clean op:"notify" push was captured on a genuine whole-apartment completion and decoded + upserted correctly.
  • Cross-confirmed against an independent implementation: the 12-field layout and names match ioBroker's Q10CleanRecordService, in addition to the ss07 app's own record parser.
  • 15 unit tests (list + notify decode, upsert dedupe, the crash-safe enums).

Honest caveats (one device observed)

  • Two enum values were never exercised on our unit: clean-mode 2 and start-method 0 (remote). They're documented as app-parser-sourced and left unmapped rather than guessed — the raw int still surfaces, and the enums return None for them.
  • Per-record map download (the map_len > 0 gate) is intentionally out of scope here — a natural follow-up.

Happy to adjust naming/structure to fit the trait conventions, and to share capture fixtures if useful.

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.
@andrewlyeats andrewlyeats force-pushed the feat/q10-clean-history branch from 80994f3 to 80b0bd6 Compare June 27, 2026 06:57

@allenporter allenporter left a comment

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.

Thank you @andrewlyeats this is fantastic. I have some code structure related comments, though overall look forward to merging this feature.

_E = TypeVar("_E", bound=IntEnum)


def _safe_clean_enum(enum_cls: type[_E], value: int | None) -> _E | 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.

Can you take a look at the enum definitions in code_mappings and use them above? I believe it will give you equivalent functionality to what is needed here, but i didn't look really closely.


The device returns each record as an underscore-delimited string in the ``data``
list of a ``{"op": "list"}`` query. The field names and meanings here follow the
device's own app, which splits the string into these 12 values. The ``*_len``

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.

don't need to mention device app here, just say its how the format works

"""Length of the saved path blob for this record (0 = none stored)."""
virtual_len: int | None = None
"""Length of the saved virtual-restriction blob for this record (0 = none stored)."""
clean_mode: int | 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.

Once you update the fields above it should be possible to just directly add the enum types here and RoborockBase should parse them correctly, so you don't need the additional enum property method converters.

params={str(B01_Q10_DP.CLEAN_RECORD.code): {"op": "list"}},
)

def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> 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.

Can you review the converter pattern used in other objects and extract separate objects for parsing the data vs updating state on the object? I realize this is more complex since it supports a merge vs an update, but i do think we still want to separate concerns a little bit more here. The trait can manage merge vs update still.

We can simplify this so we don't need two copies of the code that rebuilds the list, sorts it, and notifies, since that can happen in one place. So i think that would be:

  • define a format for the notification
  • have a class for parsing the dps to that object
  • the trait then will get the object, decide if its merging or updating (single if statement branch), then sort (one impementation) and notify (one implementation).

My gut says the string conversion logic belongs in the converter /trait and not in the container.


The device returns each record as an underscore-delimited string in the ``data``
list of a ``{"op": "list"}`` query. The field names and meanings here follow the
device's own app, which splits the string into these 12 values. The ``*_len``

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.

Can you add some full payload examples in /tests/protocols/testdata/b01_q10_protocol? it serves as nice documentation of strings we actually see in practice from the device, since it can be a little harder to understand the full payload in unit tests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants