Repository navigation
feat(v1): expose per-room clean-then-mop via customize clean mode - #984
allenporter merged 5 commits into
Conversation
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.
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.
|
Review assisted by Gemini 4 Argon in Antigravity. Note: it looks like your reply in #984 (comment) posted the literal file path Looking at the updated commit (
|
- 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.
|
Thanks, this is a thorough review and all of it checked out. Pushed 4142f21 addressing each point:
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. |
allenporter
left a comment
There was a problem hiding this comment.
Note: This review was generated using Google Antigravity with Gemini 4 Argon and the repository's code-review skill (AGENTS.md).
Thanks for addressing the previous round of feedback in 4142f219 (switching requires_feature to is_customized_clean_supported, defaulting repeat: int = 1, rejecting non-dict list items in CustomizeCleanModeConverter, and documenting the StatusTrait sentinel codes)! The PR rebases cleanly onto main and passes pytest and ruff.
Looking closely at the updated trait scope (is_customized_clean_supported), how SET_CUSTOMIZE_CLEAN_MODE behaves across multiple rooms/models, and how this coordinates with #959, I left inline comments covering:
- Public Trait API & Consumer Contract (
Priority 1): GeneralizingCustomizeCleanModeTraitbeyond a raw-intset_clean_then_mopsetter (so devices without CTM and rooms without an existing override have a usable, typed API), decoupling from unmerged #959, and placing container models inroborock.data. - Feature Gating & Mixin Initialization (
Priority 1/2): Gating clean-then-mop read/write behavior onis_clean_then_mop_mode_supportedviaDeviceFeaturesTrait, and ensuringV1TraitMixin.__init__runs despite dataclass MRO. - Multi-Room Data Loss & Model-Specific Fields (
Priority 2): Preserving other segments' overrides and extra per-segment fields (e.g.mop_power,custom_water_box_distance) instead of overwriting the table with a 1-elementparams=[entry]. - Tests & Fixtures: Using existing
mock_data.DEVICE_PRODUCT_PAIRS(home_data_device_s5e.json) withdiscover_features_fixtureinstead of mutatingtrait._rpc_channel, and fixing theenabled=Falseassertion intest_set_clean_then_mop.
Addresses a second review round on Python-roborock#984 (allenporter, 2026-11-01) plus two rounds of independent review by a second AI reviewer, both of which reproduced real bugs rather than just raising theoretical concerns: - Move SegmentCleanMode/CustomizeCleanModes to data/v1/v1_containers.py (re-exported via roborock.data), matching how other trait container models (WashTowelMode, MopDryerSetting, etc.) are organized. Decouple the module docstring from unmerged Python-roborock#959 (StatusTrait.clean_then_mop doesn't exist on main). - CustomizeCleanModeTrait now takes a DeviceFeaturesTrait in __init__ (mirroring WashTowelModeTrait) instead of relying on the dataclass MRO to run V1TraitMixin.__init__, which it silently didn't. set_clean_then_mop now raises RoborockUnsupportedFeature up front if is_clean_then_mop_mode_supported is False, instead of writing seq_type to firmware that doesn't support it - many per-room-custom-mode devices (S7 MaxV, S8, Qrevo) don't support clean-then-mop specifically. - set_clean_then_mop's fan_power/water_box_mode/mop_mode now accept the typed VacuumModes/WaterModes/CleanRoutes enums (or a raw int, for codes this library doesn't model) instead of only raw ints. - The read-merge-write sequence (refresh, build payload, SET, refresh) is now serialized per trait instance via an asyncio.Lock. Reproduced the race without it: two concurrent calls enabling different rooms each read the same stale table, and the second call's write silently reverted the first call's room. - set_clean_then_mop sends the full known table on every write, not a single-entry list: SET_CUSTOMIZE_CLEAN_MODE replaces the whole customized-segment table on at least some firmware (first-hand Saros 20 Sonic report: https://github.com/orgs/home-assistant/discussions/3998), so a single-entry write silently wiped other rooms' overrides. - Untouched rooms are round-tripped from their raw wire dict (now cached by refresh(), keyed by the canonical typed segment id - not the raw dict's own possibly-differently-cased/typed "segment" key, which reproducibly desynced the cache from by_segment) rather than reconstructed from typed SegmentCleanMode fields, so fields this library doesn't model at all (not just the two now added to the dataclass) survive a write instead of being silently dropped. A response with two entries resolving to the same segment id now raises RoborockParsingException instead of one silently overwriting the other in the cache. - Tests: switched the fixture to real device discovery (device + discover_features_fixture, Saros 10R for the clean-then-mop-supported path, S7 MaxV default for the unsupported path, S5E for is_customized_clean_supported=False) instead of constructing the trait directly and poking private attributes. Fixed the enabled=False case never actually being exercised (the mocked post-write refresh was a static always-enabled fixture regardless of what was written). Added regression tests for multi-room preservation, unmodeled-field preservation (including on an untouched room), concurrent-call serialization (verified to actually fail without the lock), and the three segment-identity cases above (also verified to fail against the prior raw-indexing approach). Addresses review feedback on Python-roborock#984.
|
Thanks for the deep second pass - all of this is addressed now in
Verification run just now: focused suite 108 passed (incl. conformance), full suite |
There was a problem hiding this comment.
Note: This review was generated using Google Antigravity with Gemini 4 Argon and the repository's code-review skill (AGENTS.md).
Thanks for the thorough update in a1b32790! Moving SegmentCleanMode and CustomizeCleanModes to v1_containers.py (covered by test_model_conformance.py), decoupling from #959, injecting DeviceFeaturesTrait with V1TraitMixin.__init__, accepting typed mode enums, serializing the read-merge-write path under asyncio.Lock while preserving untouched segments and unmodeled fields, and reworking the tests around device.v1_properties.customize_clean_mode all look great.
The branch merges cleanly with main (v7.17.0) and passes pytest, ruff, and mypy. Just a few small items left before we get this merged:
- CI Blocker (
codespell):uv run pre-commit run --all-filesflags a typo (unsupport) intest_customize_clean_mode.py. - Public Trait API & Consumer Contract (
Priority 1): Clarifying the non-CTM docstring onCustomizeCleanModeTrait, prefixing_SEQ_TYPE_CLEAN_THEN_MOPso it isn't wildcard-exported ontoroborock.*, and rejecting sentinel mode codes (CUSTOMIZED/SMART_MODE) at runtime inset_clean_then_mop. - Minor Cleanup (
Priority 2): Removing unused_WRITE_KEYS, parameterizingdict[int, dict[str, Any]], and tightening the inline hardware-verification comment.
| Uses the default device fixture (S7 MaxV / a27): it genuinely supports | ||
| per-room custom modes but not clean-then-mop specifically, matching real | ||
| hardware, rather than faking unsupport on a device that does support it. |
There was a problem hiding this comment.
CI Blocker (pre-commit / codespell):
uv run pre-commit run --all-files fails codespell (exit code 65) on unsupport:
| hardware, rather than faking unsupport on a device that does support it. | |
| hardware, rather than faking lack of support on a device that does support it. |
| (which only gates the narrower `seq_type` axis within it). A device can | ||
| support per-room custom modes without supporting clean-then-mop | ||
| specifically; on such a device `SegmentCleanMode.seq_type` (and therefore | ||
| `.clean_then_mop`) is simply `None` for every room, same as when the | ||
| device never reports it. `set_clean_then_mop` additionally checks | ||
| `is_clean_then_mop_mode_supported` itself before writing, since writing | ||
| `seq_type` to firmware that doesn't support it is not just a no-op read. | ||
| """ | ||
|
|
||
| command = RoborockCommand.GET_CUSTOMIZE_CLEAN_MODE | ||
| converter = CustomizeCleanModeConverter() | ||
| requires_feature = "is_customized_clean_supported" | ||
|
|
||
| def __init__(self, device_features: DeviceFeaturesTrait) -> None: | ||
| super().__init__() | ||
| common.V1TraitMixin.__init__(self) | ||
| self._device_features = device_features | ||
| self._raw_by_segment: dict[int, dict] = {} | ||
| self._write_lock = asyncio.Lock() |
There was a problem hiding this comment.
Expose is_clean_then_mop_supported & fix non-CTM docstring claim (AGENTS.md Priority 1):
CustomizeCleanModeTraitis discovered on allis_customized_clean_supporteddevices (e.g. S7 MaxV, S8, Qrevo), most of which haveis_clean_then_mop_mode_supported == False. Becauseself._device_featuresis private, a consumer holdingcustomize_clean_modehas no public property on the trait itself to check whetherset_clean_then_mopis supported without reaching across tov1_properties.device_features.is_clean_then_mop_mode_supportedor catchingRoborockUnsupportedFeature.- Lines 44–46 still assert that on a non-CTM device "
SegmentCleanMode.seq_type(and therefore.clean_then_mop) is simplyNonefor every room", even though nothing inrefresh()enforces that (andtest_refreshruns on the S7 MaxV fixture withseq_type=0/1). If non-CTM V1 firmware still serializes"seq_type": 0, a consumer checkingmode.clean_then_mop is not Nonewould render a toggle that always raisesRoborockUnsupportedFeature.
Please expose a public @property def is_clean_then_mop_supported(self) -> bool delegating to self._device_features.is_clean_then_mop_mode_supported (similar to how WashTowelModeTrait and StatusTrait expose capability-derived properties directly on the trait), and update the docstring to direct callers to is_clean_then_mop_supported rather than assuming seq_type is omitted.
There was a problem hiding this comment.
Small follow-up on (1): since v1_properties.device_features.is_clean_then_mop_mode_supported is already public on PropertiesApi, adding a separate is_clean_then_mop_supported property on CustomizeCleanModeTrait is optional—either exposing it on the trait for convenience or simply updating the docstring here to point callers to device_features.is_clean_then_mop_mode_supported (instead of assuming SegmentCleanMode.seq_type is guaranteed None on non-CTM firmware) works!
|
|
||
| from .common import V1ResponseData | ||
|
|
||
| _WRITE_KEYS = ("segment", "fan_power", "water_box_mode", "mop_mode", "repeat", "seq_type") |
There was a problem hiding this comment.
Dead code (_WRITE_KEYS) & bare dict annotations (AGENTS.md §1):
_WRITE_KEYSis unused anywhere in the module or tests and can be removed.- Please parameterize
self._raw_by_segment: dict[int, dict[str, Any]] = {}(line 59) andraw_by_segment: dict[int, dict[str, Any]] = {}(line 93) instead of baredict.
| seq_type = SEQ_TYPE_CLEAN_THEN_MOP if enabled else 0 | ||
| new_entry = { | ||
| "segment": segment, | ||
| "fan_power": fan_power.code if isinstance(fan_power, VacuumModes) else fan_power, | ||
| "water_box_mode": water_box_mode.code if isinstance(water_box_mode, WaterModes) else water_box_mode, | ||
| "mop_mode": mop_mode.code if isinstance(mop_mode, CleanRoutes) else mop_mode, | ||
| "repeat": repeat, | ||
| "seq_type": seq_type, | ||
| } | ||
| # Start from this room's raw wire dict (if it already had an | ||
| # override) so any field this library doesn't model - or | ||
| # couldn't parse - survives the write, and only overlay the keys | ||
| # this call actually changes. A room with no prior override | ||
| # gets only the known fields; the device fills in its own | ||
| # per-model defaults for the rest, same as the original | ||
| # single-entry write did. | ||
| raw_entries = dict(self._raw_by_segment) | ||
| raw_entries[segment] = {**raw_entries.get(segment, {}), **new_entry} | ||
|
|
||
| # Send the full known table, not just the changed room: on at | ||
| # least some V1 firmware (confirmed first-hand for a Saros 20 | ||
| # Sonic, see https://github.com/orgs/home-assistant/discussions/3998), | ||
| # SET_CUSTOMIZE_CLEAN_MODE replaces the customized segment table | ||
| # rather than patching a single entry, so sending only | ||
| # `[new_entry]` would silently wipe every other room's | ||
| # override. This has not been re-verified against real hardware | ||
| # for THIS device since this fix was written (no second | ||
| # customized room available to test with); please confirm on a | ||
| # device with two or more customized rooms before relying on | ||
| # it. |
There was a problem hiding this comment.
Sentinel mode validation & source comment (AGENTS.md Priority 1/2):
- Reject sentinel mode codes at runtime: Lines 127–132 document not to pass
StatusTrait's sentinelCUSTOMIZEDcodes (fan_power=106,water_box_mode=204,mop_mode=302), andSMART_MODE(110/209/306) is a sentinel as well—yet theVacuumModes | int/WaterModes | int/CleanRoutes | intannotations statically permit those enum members. Validating against those sentinel codes up front (raisingValueErrorbefore acquiringself._write_lock) turns the docstring warning into a safe runtime check. - Inline hardware note: Lines 194–198 ("This has not been re-verified against real hardware for THIS device since this fix was written...") read like a PR comment rather than permanent library documentation—condensing that comment to state the full-table wire behavior directly (or verifying a two-room round-trip if convenient) would keep the module clean.
|
Hey @jevgenijostin — stepping back for a second: thank you for your patience through multiple rounds here, and sorry for putting you through repeated waves of noisy AI-assisted review comments and shifting nits. The core work in Please don't feel obligated to spend another round chasing the comments above — I'd love to help accelerate getting this across the finish line (we can push a small co-authored follow-up commit on our end to handle the remaining debug statements / API polish and get @Lash-L to review, unless you're already mid-edit and prefer to push yourself). Just let me know if you're working on anything locally so we don't step on your branch! |
Summary
Follow-up to #959, which adds the global
clean_then_mopflag fromget_statusbut explicitly leaves the per-room layer alone:This PR models that per-room layer: a
CustomizeCleanModeTraitthat readsget_customize_clean_modeinto a list ofSegmentCleanMode(one per room with an override) and aset_clean_then_mop(segment, enabled, ...)setter that writesset_customize_clean_mode.Verification
Tested against a Roborock QV 35S (
roborock.vacuum.a168).get_customize_clean_modestarts empty ([]) until a room has actually had a custom mode set from the app or API. Round-tripped a write/read on one room:then reset back to
seq_type: 0to leave the room's real setting unchanged.Notes
is_clean_then_mop_mode_supportedas 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.StatusTrait.clean_then_mop/set_clean_then_mop's shape and docstrings at room granularity on purpose, so the two read the same way.uv run pytestpasses locally; new tests intests/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.