From 026f6b1cba9df77357c31d7adea99b882b1bbc15 Mon Sep 17 00:00:00 2001 From: Jevgeni Jostin Date: Sat, 17 Oct 2026 17:12:49 +0200 Subject: [PATCH 1/3] feat(v1): expose per-room clean-then-mop via customize clean mode Follow-up to #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. --- roborock/devices/traits/v1/__init__.py | 4 + .../devices/traits/v1/customize_clean_mode.py | 117 ++++++++++++++++++ .../traits/v1/test_customize_clean_mode.py | 112 +++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 roborock/devices/traits/v1/customize_clean_mode.py create mode 100644 tests/devices/traits/v1/test_customize_clean_mode.py diff --git a/roborock/devices/traits/v1/__init__.py b/roborock/devices/traits/v1/__init__.py index e3f93d4b7..4ea9b64d8 100644 --- a/roborock/devices/traits/v1/__init__.py +++ b/roborock/devices/traits/v1/__init__.py @@ -74,6 +74,7 @@ command, common, consumeable, + customize_clean_mode, device_features, do_not_disturb, dust_collection_mode, @@ -98,6 +99,7 @@ from .command import CommandTrait from .common import V1TraitMixin from .consumeable import ConsumableTrait +from .customize_clean_mode import CustomizeCleanModeTrait from .device_features import DeviceFeaturesTrait from .do_not_disturb import DoNotDisturbTrait from .dust_collection_mode import DustCollectionModeTrait @@ -126,6 +128,7 @@ "command", "common", "consumeable", + "customize_clean_mode", "device_features", "do_not_disturb", "dust_collection_mode", @@ -179,6 +182,7 @@ class PropertiesApi(Trait): smart_wash_params: SmartWashParamsTrait | None = None obstacle_photos: ObstaclePhotoTrait | None = None mop_dryer: MopDryerTrait | None = None + customize_clean_mode: CustomizeCleanModeTrait | None = None def __init__( self, diff --git a/roborock/devices/traits/v1/customize_clean_mode.py b/roborock/devices/traits/v1/customize_clean_mode.py new file mode 100644 index 000000000..e348cc306 --- /dev/null +++ b/roborock/devices/traits/v1/customize_clean_mode.py @@ -0,0 +1,117 @@ +"""Trait for per-room (segment) cleaning mode overrides on Roborock devices. + +This exposes the same "vacuum then mop" axis as `StatusTrait.clean_then_mop` +(the global, per-job flag), but scoped to a single room. Where the global +flag is per-job and clears after every clean, a per-room override set here +is persisted on the device: it is a preference attached to that room and is +re-applied whenever the room is cleaned, until overwritten or cleared from +the vendor app. + +Rooms without an override are simply absent from `modes` - the robot falls +back to the global settings (fan power, water box mode, mop mode and +clean-then-mop) for those. An empty list means no room currently has one. +""" + +from dataclasses import dataclass + +from roborock.data import RoborockBase +from roborock.devices.traits.v1 import common +from roborock.roborock_typing import RoborockCommand + +from .common import V1ResponseData + +_SEQ_TYPE_CLEAN_THEN_MOP = 1 + + +@dataclass +class SegmentCleanMode(RoborockBase): + """A single room's clean mode override, as reported by the device.""" + + segment: int + fan_power: int | None = None + water_box_mode: int | None = None + mop_mode: int | None = None + repeat: int | None = None + seq_type: int | None = None + + @property + def clean_then_mop(self) -> bool | None: + """Whether this room is vacuumed fully before it is mopped. + + `None` means the device did not report a sequence type for this + room's override. + """ + if self.seq_type is None: + return None + return self.seq_type == _SEQ_TYPE_CLEAN_THEN_MOP + + +@dataclass +class CustomizeCleanModes(RoborockBase): + """Collection of per-room clean mode overrides.""" + + modes: list[SegmentCleanMode] | None = None + + @property + def by_segment(self) -> dict[int, SegmentCleanMode]: + """Index the overrides by segment id.""" + return {mode.segment: mode for mode in self.modes or []} + + +class CustomizeCleanModeConverter(common.V1TraitDataConverter): + """Converts get_customize_clean_mode responses to CustomizeCleanModes.""" + + def convert(self, response: V1ResponseData) -> CustomizeCleanModes: + """Parse the response from the device into a CustomizeCleanModes.""" + if not isinstance(response, list): + raise ValueError(f"Unexpected CustomizeCleanModes response format: {response!r}") + return CustomizeCleanModes( + modes=[SegmentCleanMode.from_dict(entry) for entry in response if isinstance(entry, dict)] + ) + + +class CustomizeCleanModeTrait(CustomizeCleanModes, common.V1TraitMixin): + """Trait for reading and writing per-room clean mode overrides. + + This is the per-room counterpart to `StatusTrait.clean_then_mop` / + `StatusTrait.set_clean_then_mop`. Both are gated by the same device + feature: no device has been found that supports the global toggle but + not the per-room one, since the vendor app presents them as a single + "vacuum then mop" switch that can be applied globally or per room. + """ + + command = RoborockCommand.GET_CUSTOMIZE_CLEAN_MODE + converter = CustomizeCleanModeConverter() + requires_feature = "is_clean_then_mop_mode_supported" + + async def set_clean_then_mop( + self, + segment: int, + enabled: bool, + *, + fan_power: int, + water_box_mode: int, + mop_mode: int | None = None, + repeat: int | None = None, + ) -> None: + """Set whether a single room is vacuumed fully before it is mopped. + + The firmware requires the room's full clean mode alongside the + sequence type, not the flag on its own, so callers pass through the + fan power / water box mode (and optionally mop mode / repeat) that + room already cleans with - typically read from `StatusTrait` - so + this call only changes the order, not the settings. + """ + seq_type = _SEQ_TYPE_CLEAN_THEN_MOP if enabled else 0 + entry: dict[str, int] = { + "segment": segment, + "fan_power": fan_power, + "water_box_mode": water_box_mode, + "seq_type": seq_type, + } + if mop_mode is not None: + entry["mop_mode"] = mop_mode + if repeat is not None: + entry["repeat"] = repeat + await self.rpc_channel.send_command(RoborockCommand.SET_CUSTOMIZE_CLEAN_MODE, params=[entry]) + await self.refresh() diff --git a/tests/devices/traits/v1/test_customize_clean_mode.py b/tests/devices/traits/v1/test_customize_clean_mode.py new file mode 100644 index 000000000..46f056c67 --- /dev/null +++ b/tests/devices/traits/v1/test_customize_clean_mode.py @@ -0,0 +1,112 @@ +"""Tests for the CustomizeCleanModeTrait class.""" + +from unittest.mock import AsyncMock, call + +import pytest + +from roborock.devices.traits.v1.customize_clean_mode import ( + CustomizeCleanModeTrait, + SegmentCleanMode, +) +from roborock.exceptions import RoborockParsingException +from roborock.roborock_typing import RoborockCommand + +CUSTOMIZE_CLEAN_MODE_DATA = [ + {"segment": 4, "fan_power": 102, "water_box_mode": 203, "mop_mode": 300, "repeat": 1, "seq_type": 0}, + {"segment": 7, "fan_power": 101, "water_box_mode": 203, "mop_mode": 300, "repeat": 1, "seq_type": 1}, +] + + +@pytest.fixture(name="customize_clean_mode_trait") +def customize_clean_mode_trait_fixture(mock_rpc_channel: AsyncMock) -> CustomizeCleanModeTrait: + """Create a CustomizeCleanModeTrait with mocked dependencies. + + This is constructed directly rather than via the `device` fixture: unlike + the dock-gated optional traits, there is no existing test helper for + parametrizing the `new_feature_info` bit this trait is gated on, and the + other `requires_feature`-gated optional traits (child_lock, led_status, + valley_electricity_timer) do not have discovery-level tests either. + """ + trait = CustomizeCleanModeTrait() + trait._rpc_channel = mock_rpc_channel # type: ignore[assignment] + return trait + + +async def test_refresh(customize_clean_mode_trait: CustomizeCleanModeTrait, mock_rpc_channel: AsyncMock) -> None: + """Rooms with an override are parsed into SegmentCleanMode entries.""" + mock_rpc_channel.send_command.side_effect = [CUSTOMIZE_CLEAN_MODE_DATA] + + await customize_clean_mode_trait.refresh() + + mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_CUSTOMIZE_CLEAN_MODE) + assert customize_clean_mode_trait.modes == [ + SegmentCleanMode(segment=4, fan_power=102, water_box_mode=203, mop_mode=300, repeat=1, seq_type=0), + SegmentCleanMode(segment=7, fan_power=101, water_box_mode=203, mop_mode=300, repeat=1, seq_type=1), + ] + assert customize_clean_mode_trait.by_segment[7].clean_then_mop is True + assert customize_clean_mode_trait.by_segment[4].clean_then_mop is False + + +async def test_refresh_no_overrides( + customize_clean_mode_trait: CustomizeCleanModeTrait, mock_rpc_channel: AsyncMock +) -> None: + """An empty response means no room currently has a custom clean mode.""" + mock_rpc_channel.send_command.side_effect = [[]] + + await customize_clean_mode_trait.refresh() + + assert customize_clean_mode_trait.modes == [] + assert customize_clean_mode_trait.by_segment == {} + + +async def test_refresh_bad_format( + customize_clean_mode_trait: CustomizeCleanModeTrait, mock_rpc_channel: AsyncMock +) -> None: + """A non-list response is a parsing error, not a silent empty result.""" + mock_rpc_channel.send_command.side_effect = [{"unexpected": "shape"}] + + with pytest.raises(RoborockParsingException): + await customize_clean_mode_trait.refresh() + + +@pytest.mark.parametrize(("seq_type", "expected"), [(1, True), (0, False), (None, None)]) +def test_clean_then_mop(seq_type: int | None, expected: bool | None) -> None: + """seq_type reports whether a room is vacuumed before it is mopped.""" + mode = SegmentCleanMode(segment=1, seq_type=seq_type) + + assert mode.clean_then_mop is expected + + +@pytest.mark.parametrize( + ("enabled", "mop_mode", "repeat", "expected_params"), + [ + ( + True, + 300, + 1, + {"segment": 7, "fan_power": 101, "water_box_mode": 203, "seq_type": 1, "mop_mode": 300, "repeat": 1}, + ), + (True, None, None, {"segment": 7, "fan_power": 101, "water_box_mode": 203, "seq_type": 1}), + (False, 300, None, {"segment": 7, "fan_power": 101, "water_box_mode": 203, "seq_type": 0, "mop_mode": 300}), + ], +) +async def test_set_clean_then_mop( + customize_clean_mode_trait: CustomizeCleanModeTrait, + mock_rpc_channel: AsyncMock, + enabled: bool, + mop_mode: int | None, + repeat: int | None, + expected_params: dict[str, int], +) -> None: + """The setter sends the room's full clean mode alongside the sequence type, then refreshes.""" + mock_rpc_channel.send_command.side_effect = ["ok", CUSTOMIZE_CLEAN_MODE_DATA] + + await customize_clean_mode_trait.set_clean_then_mop( + 7, enabled, fan_power=101, water_box_mode=203, mop_mode=mop_mode, repeat=repeat + ) + + assert mock_rpc_channel.send_command.mock_calls == [ + call(RoborockCommand.SET_CUSTOMIZE_CLEAN_MODE, params=[expected_params]), + call(RoborockCommand.GET_CUSTOMIZE_CLEAN_MODE), + ] + assert customize_clean_mode_trait.by_segment[7].clean_then_mop is True From 515b9ba57367522103c0c60572177c4b831d0bd7 Mon Sep 17 00:00:00 2001 From: Jevgeni Jostin Date: Sat, 17 Oct 2026 22:43:56 +0200 Subject: [PATCH 2/3] fix(v1): require mop_mode in set_clean_then_mop, document repeat reset 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 #984. --- .../devices/traits/v1/customize_clean_mode.py | 23 ++++++++++++++----- .../traits/v1/test_customize_clean_mode.py | 15 ++++++------ 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/roborock/devices/traits/v1/customize_clean_mode.py b/roborock/devices/traits/v1/customize_clean_mode.py index e348cc306..8ebf87d73 100644 --- a/roborock/devices/traits/v1/customize_clean_mode.py +++ b/roborock/devices/traits/v1/customize_clean_mode.py @@ -91,26 +91,37 @@ async def set_clean_then_mop( *, fan_power: int, water_box_mode: int, - mop_mode: int | None = None, + mop_mode: int, repeat: int | None = None, ) -> None: """Set whether a single room is vacuumed fully before it is mopped. The firmware requires the room's full clean mode alongside the sequence type, not the flag on its own, so callers pass through the - fan power / water box mode (and optionally mop mode / repeat) that - room already cleans with - typically read from `StatusTrait` - so - this call only changes the order, not the settings. + fan power / water box mode / mop mode that room already cleans with + - typically read from the room's existing `SegmentCleanMode` (via + `by_segment`) or, if the room has no override yet, from `StatusTrait` + - so this call only changes the order, not the settings. + + `fan_power`, `water_box_mode` and `mop_mode` are all required: the + device rejects the request outright (`Param error, no mop_mode`) if + `mop_mode` is left out, confirmed against a real device - this isn't + a case where a missing field is silently defaulted. + + `repeat` is the one genuinely optional field, but omitting it is NOT + a no-op: the device resets it to its own default (observed: 1) + rather than preserving whatever the room's `repeat` was set to + before this call - also confirmed against a real device. Pass the + room's current `repeat` explicitly if you want it preserved. """ seq_type = _SEQ_TYPE_CLEAN_THEN_MOP if enabled else 0 entry: dict[str, int] = { "segment": segment, "fan_power": fan_power, "water_box_mode": water_box_mode, + "mop_mode": mop_mode, "seq_type": seq_type, } - if mop_mode is not None: - entry["mop_mode"] = mop_mode if repeat is not None: entry["repeat"] = repeat await self.rpc_channel.send_command(RoborockCommand.SET_CUSTOMIZE_CLEAN_MODE, params=[entry]) diff --git a/tests/devices/traits/v1/test_customize_clean_mode.py b/tests/devices/traits/v1/test_customize_clean_mode.py index 46f056c67..9ad652288 100644 --- a/tests/devices/traits/v1/test_customize_clean_mode.py +++ b/tests/devices/traits/v1/test_customize_clean_mode.py @@ -78,31 +78,30 @@ def test_clean_then_mop(seq_type: int | None, expected: bool | None) -> None: @pytest.mark.parametrize( - ("enabled", "mop_mode", "repeat", "expected_params"), + ("enabled", "repeat", "expected_params"), [ ( True, - 300, 1, - {"segment": 7, "fan_power": 101, "water_box_mode": 203, "seq_type": 1, "mop_mode": 300, "repeat": 1}, + {"segment": 7, "fan_power": 101, "water_box_mode": 203, "mop_mode": 300, "seq_type": 1, "repeat": 1}, ), - (True, None, None, {"segment": 7, "fan_power": 101, "water_box_mode": 203, "seq_type": 1}), - (False, 300, None, {"segment": 7, "fan_power": 101, "water_box_mode": 203, "seq_type": 0, "mop_mode": 300}), + (True, None, {"segment": 7, "fan_power": 101, "water_box_mode": 203, "mop_mode": 300, "seq_type": 1}), + (False, None, {"segment": 7, "fan_power": 101, "water_box_mode": 203, "mop_mode": 300, "seq_type": 0}), ], ) async def test_set_clean_then_mop( customize_clean_mode_trait: CustomizeCleanModeTrait, mock_rpc_channel: AsyncMock, enabled: bool, - mop_mode: int | None, repeat: int | None, expected_params: dict[str, int], ) -> None: - """The setter sends the room's full clean mode alongside the sequence type, then refreshes.""" + """The setter always sends mop_mode (the device rejects the request without it) alongside + the room's other settings and the sequence type, then refreshes.""" mock_rpc_channel.send_command.side_effect = ["ok", CUSTOMIZE_CLEAN_MODE_DATA] await customize_clean_mode_trait.set_clean_then_mop( - 7, enabled, fan_power=101, water_box_mode=203, mop_mode=mop_mode, repeat=repeat + 7, enabled, fan_power=101, water_box_mode=203, mop_mode=300, repeat=repeat ) assert mock_rpc_channel.send_command.mock_calls == [ From 4142f219d6ffa7bc8758cdbf30e36f1d0bb5d39d Mon Sep 17 00:00:00 2001 From: Jevgeni Jostin Date: Sun, 18 Oct 2026 08:08:49 +0200 Subject: [PATCH 3/3] fix(v1): correct feature gate, require repeat, reject malformed entries - 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 #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 #984. --- .../devices/traits/v1/customize_clean_mode.py | 53 +++++++++++-------- .../traits/v1/test_customize_clean_mode.py | 36 ++++++++++--- 2 files changed, 59 insertions(+), 30 deletions(-) diff --git a/roborock/devices/traits/v1/customize_clean_mode.py b/roborock/devices/traits/v1/customize_clean_mode.py index 8ebf87d73..e71f76f9b 100644 --- a/roborock/devices/traits/v1/customize_clean_mode.py +++ b/roborock/devices/traits/v1/customize_clean_mode.py @@ -1,9 +1,10 @@ """Trait for per-room (segment) cleaning mode overrides on Roborock devices. This exposes the same "vacuum then mop" axis as `StatusTrait.clean_then_mop` -(the global, per-job flag), but scoped to a single room. Where the global -flag is per-job and clears after every clean, a per-room override set here -is persisted on the device: it is a preference attached to that room and is +(the global, per-job flag), but scoped to a single room. The global flag is +itself persisted on the device - tracked while docked, not cleared after +every clean (see `StatusTrait.clean_then_mop`) - and a per-room override set +here is likewise persisted: it is a preference attached to that room and is re-applied whenever the room is cleaned, until overwritten or cleared from the vendor app. @@ -63,26 +64,27 @@ class CustomizeCleanModeConverter(common.V1TraitDataConverter): def convert(self, response: V1ResponseData) -> CustomizeCleanModes: """Parse the response from the device into a CustomizeCleanModes.""" - if not isinstance(response, list): + if not isinstance(response, list) or not all(isinstance(entry, dict) for entry in response): raise ValueError(f"Unexpected CustomizeCleanModes response format: {response!r}") - return CustomizeCleanModes( - modes=[SegmentCleanMode.from_dict(entry) for entry in response if isinstance(entry, dict)] - ) + return CustomizeCleanModes(modes=[SegmentCleanMode.from_dict(entry) for entry in response]) class CustomizeCleanModeTrait(CustomizeCleanModes, common.V1TraitMixin): """Trait for reading and writing per-room clean mode overrides. This is the per-room counterpart to `StatusTrait.clean_then_mop` / - `StatusTrait.set_clean_then_mop`. Both are gated by the same device - feature: no device has been found that supports the global toggle but - not the per-room one, since the vendor app presents them as a single - "vacuum then mop" switch that can be applied globally or per room. + `StatusTrait.set_clean_then_mop`. Gated on `is_customized_clean_supported` + - the feature that covers `get/set_customize_clean_mode` in general, not + `is_clean_then_mop_mode_supported` (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. """ command = RoborockCommand.GET_CUSTOMIZE_CLEAN_MODE converter = CustomizeCleanModeConverter() - requires_feature = "is_clean_then_mop_mode_supported" + requires_feature = "is_customized_clean_supported" async def set_clean_then_mop( self, @@ -92,7 +94,7 @@ async def set_clean_then_mop( fan_power: int, water_box_mode: int, mop_mode: int, - repeat: int | None = None, + repeat: int = 1, ) -> None: """Set whether a single room is vacuumed fully before it is mopped. @@ -100,19 +102,27 @@ async def set_clean_then_mop( sequence type, not the flag on its own, so callers pass through the fan power / water box mode / mop mode that room already cleans with - typically read from the room's existing `SegmentCleanMode` (via - `by_segment`) or, if the room has no override yet, from `StatusTrait` - - so this call only changes the order, not the settings. + `by_segment`) - so this call only changes the order, not the + settings. + + Do NOT source these from `StatusTrait` as a fallback when a room has + no override yet: while the device is in a per-room custom cleaning + mode, `StatusTrait` itself reports the sentinel "customized" codes + (`fan_power=106`, `water_box_mode=204`, `mop_mode=302`) rather than + real settings, and those sentinels are not valid values to echo back + here. `fan_power`, `water_box_mode` and `mop_mode` are all required: the device rejects the request outright (`Param error, no mop_mode`) if `mop_mode` is left out, confirmed against a real device - this isn't a case where a missing field is silently defaulted. - `repeat` is the one genuinely optional field, but omitting it is NOT - a no-op: the device resets it to its own default (observed: 1) - rather than preserving whatever the room's `repeat` was set to - before this call - also confirmed against a real device. Pass the - room's current `repeat` explicitly if you want it preserved. + `repeat` defaults to `1` rather than being left out when the caller + doesn't have an opinion: omitting it from the wire request is NOT a + no-op on the device, which resets it to `1` instead of preserving + whatever the room's `repeat` was set to before this call - confirmed + against a real device. Pass the room's current `repeat` explicitly if + you want a different value preserved. """ seq_type = _SEQ_TYPE_CLEAN_THEN_MOP if enabled else 0 entry: dict[str, int] = { @@ -120,9 +130,8 @@ async def set_clean_then_mop( "fan_power": fan_power, "water_box_mode": water_box_mode, "mop_mode": mop_mode, + "repeat": repeat, "seq_type": seq_type, } - if repeat is not None: - entry["repeat"] = repeat await self.rpc_channel.send_command(RoborockCommand.SET_CUSTOMIZE_CLEAN_MODE, params=[entry]) await self.refresh() diff --git a/tests/devices/traits/v1/test_customize_clean_mode.py b/tests/devices/traits/v1/test_customize_clean_mode.py index 9ad652288..447221eaa 100644 --- a/tests/devices/traits/v1/test_customize_clean_mode.py +++ b/tests/devices/traits/v1/test_customize_clean_mode.py @@ -69,6 +69,16 @@ async def test_refresh_bad_format( await customize_clean_mode_trait.refresh() +async def test_refresh_bad_list_entry( + customize_clean_mode_trait: CustomizeCleanModeTrait, mock_rpc_channel: AsyncMock +) -> None: + """A malformed entry inside the list is a parsing error, not silently dropped.""" + mock_rpc_channel.send_command.side_effect = [["ok"]] + + with pytest.raises(RoborockParsingException): + await customize_clean_mode_trait.refresh() + + @pytest.mark.parametrize(("seq_type", "expected"), [(1, True), (0, False), (None, None)]) def test_clean_then_mop(seq_type: int | None, expected: bool | None) -> None: """seq_type reports whether a room is vacuumed before it is mopped.""" @@ -82,11 +92,19 @@ def test_clean_then_mop(seq_type: int | None, expected: bool | None) -> None: [ ( True, - 1, - {"segment": 7, "fan_power": 101, "water_box_mode": 203, "mop_mode": 300, "seq_type": 1, "repeat": 1}, + 2, + {"segment": 7, "fan_power": 101, "water_box_mode": 203, "mop_mode": 300, "repeat": 2, "seq_type": 1}, + ), + ( + True, + None, + {"segment": 7, "fan_power": 101, "water_box_mode": 203, "mop_mode": 300, "repeat": 1, "seq_type": 1}, + ), + ( + False, + None, + {"segment": 7, "fan_power": 101, "water_box_mode": 203, "mop_mode": 300, "repeat": 1, "seq_type": 0}, ), - (True, None, {"segment": 7, "fan_power": 101, "water_box_mode": 203, "mop_mode": 300, "seq_type": 1}), - (False, None, {"segment": 7, "fan_power": 101, "water_box_mode": 203, "mop_mode": 300, "seq_type": 0}), ], ) async def test_set_clean_then_mop( @@ -96,13 +114,15 @@ async def test_set_clean_then_mop( repeat: int | None, expected_params: dict[str, int], ) -> None: - """The setter always sends mop_mode (the device rejects the request without it) alongside + """The setter always sends mop_mode and repeat (the device rejects a request missing + mop_mode, and silently resets repeat to 1 rather than preserving it if left out) alongside the room's other settings and the sequence type, then refreshes.""" mock_rpc_channel.send_command.side_effect = ["ok", CUSTOMIZE_CLEAN_MODE_DATA] + kwargs = {"fan_power": 101, "water_box_mode": 203, "mop_mode": 300} + if repeat is not None: + kwargs["repeat"] = repeat - await customize_clean_mode_trait.set_clean_then_mop( - 7, enabled, fan_power=101, water_box_mode=203, mop_mode=300, repeat=repeat - ) + await customize_clean_mode_trait.set_clean_then_mop(7, enabled, **kwargs) assert mock_rpc_channel.send_command.mock_calls == [ call(RoborockCommand.SET_CUSTOMIZE_CLEAN_MODE, params=[expected_params]),