Skip to content

feat(v1): expose per-room clean-then-mop via customize clean mode - #984

Open
jevgenijostin wants to merge 3 commits into
Python-roborock:mainfrom
jevgenijostin:feat/per-room-clean-sequence
Open

jevgenijostin wants to merge 3 commits into
Python-roborock:mainfrom
jevgenijostin:feat/per-room-clean-sequence

Conversation

@jevgenijostin

Copy link
Copy Markdown

Summary

Follow-up to #959, which adds the global clean_then_mop flag from get_status but explicitly leaves the per-room layer alone:

There is also a per-room layer. get_customize_clean_mode returns seq_type and repeat per segment [...] This PR models only the global value from get_status; the per-room layer is left alone.

This PR models that per-room layer: a CustomizeCleanModeTrait that reads get_customize_clean_mode into a list of SegmentCleanMode (one per room with an override) and a set_clean_then_mop(segment, enabled, ...) setter that writes set_customize_clean_mode.

Verification

Tested against a Roborock QV 35S (roborock.vacuum.a168). get_customize_clean_mode starts empty ([]) until a room has actually had a custom mode set from the app or API. Round-tripped a write/read on one room:

set_customize_clean_mode([{"segment": 7, "fan_power": 104, "water_box_mode": 203,
                            "mop_mode": 300, "repeat": 1, "seq_type": 1}])
-> ["ok"]

get_customize_clean_mode()
-> [{"segment": 7, "fan_power": 104, "water_box_mode": 203,
     "mop_mode": 300, "repeat": 1, "seq_type": 1}]

then reset back to seq_type: 0 to leave the room's real setting unchanged.

Notes

  • Reuses is_clean_then_mop_mode_supported as the feature gate, same as feat(v1): expose clean-then-mop status from the device #959's global trait. No device has been found that supports one layer but not the other - the vendor app presents a single "vacuum then mop" toggle that can be applied globally or per room, backed by the same underlying setting.
  • Mirrors StatusTrait.clean_then_mop / set_clean_then_mop's shape and docstrings at room granularity on purpose, so the two read the same way.
  • Single device verified (QV 35S), same caveat as feat(v1): expose clean-then-mop status from the device #959.
  • uv run pytest passes locally; new tests in tests/devices/traits/v1/test_customize_clean_mode.py.

Motivation

Lets a consumer (e.g. Home Assistant) expose "vacuum then mop" as a per-room choice, matching the Roborock app's own room-settings screen, not just the global job-level toggle from #959.

Follow-up to Python-roborock#959, which models the global get_status seq_type flag but
explicitly leaves the per-room get/set_customize_clean_mode layer alone.

Adds a CustomizeCleanModeTrait exposing each room's SegmentCleanMode
(fan_power/water_box_mode/mop_mode/repeat/seq_type) and a
set_clean_then_mop(segment, enabled, ...) setter, mirroring
StatusTrait.clean_then_mop at room granularity.
seq_type = _SEQ_TYPE_CLEAN_THEN_MOP if enabled else 0
entry: dict[str, int] = {
"segment": segment,
"fan_power": fan_power,

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.

is it ok to overwrite this with 0 when not specified? It seems almost like this is intended to be used where you need to pass back the previous state of SegmentCleanMode when you want to make a change.

Just curious: Did you consider trying to make this work with the Rooms trait? I can see it is fairly complex to do so, but ultimately i think to use this effectively its going to need to get the room information from Rooms to get the needed segment information for display, so i think there could be benefit from trying to hide those details, but its not trivial.

@jevgenijostin jevgenijostin Oct 17, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@C:/Users/jevge/AppData/Local/Temp/claude/shots/review-reply2.txt

Tested against a real device: omitting mop_mode makes the device reject
the request outright ("Param error, no mop_mode"), it is not silently
defaulted. Omitting repeat does not preserve the room's existing value
either - the device resets it to 1. Address review feedback on Python-roborock#984.
@allenporter

Copy link
Copy Markdown
Contributor

Review assisted by Gemini 4 Argon in Antigravity.

Note: it looks like your reply in #984 (comment) posted the literal file path @C:/Users/jevge/AppData/Local/Temp/claude/shots/review-reply.txt instead of the file contents!

Looking at the updated commit (515b9ba5), a few architectural and API contract thoughts:

  1. Passing/updating SegmentCleanMode & avoiding silent repeat resets (CustomizeCleanModeTrait.set_clean_then_mop):
    • Since omitting repeat causes the device to reset the room's repeat back to 1 rather than preserving it, repeat: int | None = None is a footgun for callers. Either repeat should be required / defaulted to 1 (or read from the existing by_segment entry), or the setter on CustomizeCleanModeTrait could accept a SegmentCleanMode (or typed VacuumModes / WaterModes / CleanRoutes enums rather than raw wire ints).
    • Also watch out for the docstring suggestion to fall back to StatusTrait (fan_power, water_box_mode, mop_mode) when a room has no override yet: when custom room modes are active (CleaningMode.CUSTOM), StatusTrait reports the sentinel codes fan_power=106 (VacuumModes.CUSTOMIZED), water_box_mode=204 (WaterModes.CUSTOMIZED), and mop_mode=302 (CleanRoutes.CUSTOMIZED), which aren't valid per-room settings.
  2. Feature gating on CustomizeCleanModeTrait (customize_clean_mode.py#L85):
    • GET_CUSTOMIZE_CLEAN_MODE / SET_CUSTOMIZE_CLEAN_MODE is the general per-room custom mode command (is_customized_clean_supported), whereas is_clean_then_mop_mode_supported specifically gates seq_type. Gating the entire CustomizeCleanModeTrait on is_clean_then_mop_mode_supported hides the trait on devices that support per-room custom modes (fan_power, water_box_mode, mop_mode, repeat) without bit 93 (CLEAN_THEN_MOP_MODE).
  3. Silent filtering of malformed list items (CustomizeCleanModeConverter.convert):
    • if isinstance(entry, dict) silently drops non-dict list entries (e.g. ["ok"]) and returns modes=[]. It should validate all(isinstance(entry, dict) for entry in response) and raise ValueError otherwise so V1TraitMixin.refresh() raises RoborockParsingException.
  4. Docstring consistency with feat(v1): expose clean-then-mop status from the device #959 (customize_clean_mode.py#L3-L8):
  5. Trait discovery in tests (tests/devices/traits/v1/test_customize_clean_mode.py):
    • discover_features_fixture in tests/devices/traits/v1/fixtures.py is autouse=True and reads device_info.new_feature_set, so you can parametrize device_info (with bit 93 set/unset on new_feature_set) to test trait discovery via device.v1_properties.customize_clean_mode rather than constructing CustomizeCleanModeTrait() directly and assigning trait._rpc_channel.

- Gate CustomizeCleanModeTrait on is_customized_clean_supported (the
  feature that actually covers get/set_customize_clean_mode) instead of
  is_clean_then_mop_mode_supported (which only gates the seq_type axis
  within it) - a device can support per-room custom modes without
  clean-then-mop specifically.
- repeat now defaults to 1 instead of being left out of the wire request
  when unset: omitting it resets the room's repeat to 1 on the device
  rather than preserving the previous value, confirmed against a real
  device, so leaving it Optional was a footgun.
- CustomizeCleanModeConverter now rejects a response list containing a
  non-dict entry instead of silently dropping it.
- Corrected the module docstring's claim that the global clean_then_mop
  flag is per-job and clears after every clean - it's persisted, per
  StatusTrait.clean_then_mop's own docstring from Python-roborock#959.
- Documented that StatusTrait reports sentinel "customized" codes
  (fan_power=106/water_box_mode=204/mop_mode=302) while a per-room custom
  mode is active, so it must not be used as a fallback source for those
  values when calling set_clean_then_mop on a room with no prior override.

Addresses review feedback on Python-roborock#984.
@jevgenijostin

Copy link
Copy Markdown
Author

Thanks, this is a thorough review and all of it checked out. Pushed 4142f21 addressing each point:

  1. repeat footgun: now defaults to 1 in the signature instead of being omittable, so the wire request always carries an explicit value rather than relying on the device's own (surprising) reset-to-1 behavior.

    Sentinel-code fallback: you're right, I verified VacuumModes.CUSTOMIZED=106 / WaterModes.CUSTOMIZED=204 / CleanRoutes.CUSTOMIZED=302 in v1_clean_modes.py and removed the "fall back to StatusTrait" suggestion from the docstring entirely, replacing it with an explicit warning not to do that while a per-room custom mode is active.

  2. Feature gate: changed to is_customized_clean_supported, confirmed in device_features.py that this is the one actually tied to get/set_customize_clean_mode (via ProductFeatures.MOP_SHAKE_MODULE/MOP_SPIN_MODULE), while is_clean_then_mop_mode_supported only covers the seq_type sub-axis. On a device with the former but not the latter, seq_type/clean_then_mop just stay None per room, which the existing Optional modeling already handles.

  3. Converter: now raises on any non-dict list entry instead of filtering it out, so a malformed response surfaces as RoborockParsingException via refresh() like it should.

  4. Docstring: fixed, it now matches StatusTrait.clean_then_mop's own description (persisted, not per-job).

  5. Test discovery: looked into this, and it doesn't translate directly - is_customized_clean_supported is gated by product_features (derived from product_nickname/feature_info), not the new_feature_info_str bit-93 mechanism the existing _create_cleaning_mode_status_trait-style helpers parametrize. I didn't find any existing test in the suite exercising that product_features discovery path to mirror, and reverse-engineering a product_nickname fixture known to lack MOP_SHAKE_MODULE/MOP_SPIN_MODULE felt like a bigger yak-shave than this PR should take on. Kept the direct-construction style instead, consistent with how child_lock/led_status/valley_electricity_timer are tested today. Happy to revisit if you think it's worth it.

Also, sorry again for the garbled first reply, that was a genuine tooling slip on my end posting a literal file path - fixed now with the actual content.

This branch has not been deployed

No deployments
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