From 71b033c215be4cbe7260bfa4a4f2dea0983fa18f Mon Sep 17 00:00:00 2001 From: Felix Svensson Date: Tue, 15 Sep 2026 11:02:43 +0200 Subject: [PATCH 1/4] feat(v1): expose clean-then-mop status from the device The device reports seq_type in its get_status payload, but StatusV2 did not model it, so RoborockBase.from_dict discarded it along with every other unmatched key. The vendor app reads the same field off the same payload to derive its "clean then mop" state. Add seq_type to StatusV2 and a clean_then_mop property on StatusTrait that reports whether the current run vacuums each room fully before mopping it. It describes the run in progress rather than a persisted setting, and the device offers no setter, so it is read only here; the value travels outbound as a parameter of the cleaning command instead. Gated for reporting on is_clean_then_mop_mode_supported. Verified against a Roborock Saros 20 (roborock.vacuum.a288). --- roborock/data/v1/v1_containers.py | 2 ++ roborock/devices/traits/v1/status.py | 13 +++++++++++ .../devices/__snapshots__/test_v1_device.ambr | 2 +- tests/devices/traits/v1/test_status.py | 23 +++++++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/roborock/data/v1/v1_containers.py b/roborock/data/v1/v1_containers.py index 65bcba64a..97fce2562 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -171,6 +171,8 @@ class StatusV2(RoborockBase): repeat: int | None = None kct: int | None = None subdivision_sets: int | None = None + seq_type: int | None = field(default=None, metadata={"feature": "is_clean_then_mop_mode_supported"}) + """Cleaning sequence of the current run; 1 means vacuum the room, then mop it.""" @property def square_meter_clean_area(self) -> float | None: diff --git a/roborock/devices/traits/v1/status.py b/roborock/devices/traits/v1/status.py index 01eed9a55..81b6fd44e 100644 --- a/roborock/devices/traits/v1/status.py +++ b/roborock/devices/traits/v1/status.py @@ -31,6 +31,8 @@ _DPS_CONVERTER = DpsDataConverter.from_dataclass(StatusV2) +_SEQ_TYPE_CLEAN_THEN_MOP = 1 + class StatusTrait(StatusV2, common.V1TraitMixin, TraitUpdateListener): """Trait for managing the status of Roborock devices. @@ -131,6 +133,17 @@ def mop_route_name(self) -> str | None: return None return self.mop_route_mapping.get(self.mop_mode) + @property + def clean_then_mop(self) -> bool | None: + """Whether the current run vacuums each room fully before mopping it. + + Reported by the device as ``seq_type``. It describes the run in + progress; it is not a persisted setting and cannot be set directly. + """ + if self.seq_type is None: + return None + return self.seq_type == _SEQ_TYPE_CLEAN_THEN_MOP + @property def current_cleaning_mode(self) -> CleaningMode | None: return get_current_cleaning_mode( diff --git a/tests/devices/__snapshots__/test_v1_device.ambr b/tests/devices/__snapshots__/test_v1_device.ambr index bc0d8fdb3..412b70314 100644 --- a/tests/devices/__snapshots__/test_v1_device.ambr +++ b/tests/devices/__snapshots__/test_v1_device.ambr @@ -876,7 +876,7 @@ }) # --- # name: test_device_trait_command_parsing[status] - StatusTrait(adbumper_status=None, auto_dust_collection=None, avoid_count=None, back_type=None, battery=100, camera_status=None, charge_status=None, clean_area=91287500, clean_fluid_status=None, clean_percent=None, clean_time=5405, cleaning_mode_options=[, , , ], clear_water_box_status=None, collision_avoid_status=None, command=, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_cleaning_mode=, current_cleaning_mode_name='custom', current_map=0, debug_mode=None, dirty_water_box_status=None, distance_off=0, dnd_enabled=1, dock_cool_fan_status=None, dock_error_status=None, dock_state=, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=, error_code_name='none', fan_power=106, fan_speed_mapping={101: 'quiet', 102: 'balanced', 103: 'turbo', 104: 'max', 108: 'max_plus', 105: 'off', 106: 'custom'}, fan_speed_name='custom', fan_speed_options=[, , , , , , ], has_am=None, hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=, in_fresh_state=1, in_returning=0, in_warmup=None, is_battery_charging=False, is_exploring=None, is_locating=0, kct=None, lab_status=1, last_clean_t=None, lock_status=0, map_present=1, map_status=3, mop_forbidden_enable=0, mop_mode=None, mop_route_mapping={300: 'standard', 301: 'deep', 302: 'custom'}, mop_route_name=None, mop_route_options=[, , ], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=, state_name='charging', subdivision_sets=None, switch_map_mode=None, unsave_map_flag=0, unsave_map_reason=4, wash_phase=None, wash_ready=None, wash_status=None, water_box_carriage_status=0, water_box_filter_status=None, water_box_mode=204, water_box_status=0, water_mode_mapping={200: 'off', 201: 'mild', 202: 'standard', 203: 'intense', 207: 'custom_water_flow', 204: 'custom'}, water_mode_name='custom', water_mode_options=[, , , , , ], water_shortage_status=None) + StatusTrait(adbumper_status=None, auto_dust_collection=None, avoid_count=None, back_type=None, battery=100, camera_status=None, charge_status=None, clean_area=91287500, clean_fluid_status=None, clean_percent=None, clean_then_mop=None, clean_time=5405, cleaning_mode_options=[, , , ], clear_water_box_status=None, collision_avoid_status=None, command=, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_cleaning_mode=, current_cleaning_mode_name='custom', current_map=0, debug_mode=None, dirty_water_box_status=None, distance_off=0, dnd_enabled=1, dock_cool_fan_status=None, dock_error_status=None, dock_state=, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=, error_code_name='none', fan_power=106, fan_speed_mapping={101: 'quiet', 102: 'balanced', 103: 'turbo', 104: 'max', 108: 'max_plus', 105: 'off', 106: 'custom'}, fan_speed_name='custom', fan_speed_options=[, , , , , , ], has_am=None, hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=, in_fresh_state=1, in_returning=0, in_warmup=None, is_battery_charging=False, is_exploring=None, is_locating=0, kct=None, lab_status=1, last_clean_t=None, lock_status=0, map_present=1, map_status=3, mop_forbidden_enable=0, mop_mode=None, mop_route_mapping={300: 'standard', 301: 'deep', 302: 'custom'}, mop_route_name=None, mop_route_options=[, , ], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, seq_type=None, square_meter_clean_area=91.3, state=, state_name='charging', subdivision_sets=None, switch_map_mode=None, unsave_map_flag=0, unsave_map_reason=4, wash_phase=None, wash_ready=None, wash_status=None, water_box_carriage_status=0, water_box_filter_status=None, water_box_mode=204, water_box_status=0, water_mode_mapping={200: 'off', 201: 'mild', 202: 'standard', 203: 'intense', 207: 'custom_water_flow', 204: 'custom'}, water_mode_name='custom', water_mode_options=[, , , , , ], water_shortage_status=None) # --- # name: test_device_trait_command_parsing[status].1 dict({ diff --git a/tests/devices/traits/v1/test_status.py b/tests/devices/traits/v1/test_status.py index b30b24ad8..7e52146a5 100644 --- a/tests/devices/traits/v1/test_status.py +++ b/tests/devices/traits/v1/test_status.py @@ -24,6 +24,7 @@ RoborockDockTypeCode, RoborockErrorCode, RoborockStateCode, + StatusV2, ) from roborock.device_features import DeviceFeatures from roborock.devices.device import RoborockDevice @@ -916,3 +917,25 @@ def test_update_listener_ignores_unrelated(status_trait: StatusTrait) -> None: assert not event.is_set() unsubscribe() + + +@pytest.mark.parametrize( + ("seq_type", "expected"), + [(1, True), (0, False), (None, None)], +) +def test_clean_then_mop(status_trait: StatusTrait, seq_type: int | None, expected: bool | None) -> None: + """seq_type reports whether the current run vacuums before mopping.""" + status_trait.seq_type = seq_type + + assert status_trait.clean_then_mop is expected + + +def test_status_parses_seq_type() -> None: + """seq_type is read from the device status payload. + + The device reports it in get_status; before it was modelled here it was + silently dropped along with every other unmatched key. + """ + status = StatusV2.from_dict({**STATUS, "seq_type": 1}) + + assert status.seq_type == 1 From 1203f56db530cd828a1dd30b5b97b3b63206c964 Mon Sep 17 00:00:00 2001 From: Felix Svensson Date: Tue, 6 Oct 2026 10:40:39 +0200 Subject: [PATCH 2/4] feat(v1): add set_clean_then_mop and document how it relates to CleaningMode --- roborock/devices/traits/v1/status.py | 37 ++++++++++++++++++++-- roborock/roborock_typing.py | 1 + tests/devices/traits/v1/test_status.py | 44 ++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/roborock/devices/traits/v1/status.py b/roborock/devices/traits/v1/status.py index 81b6fd44e..56136413e 100644 --- a/roborock/devices/traits/v1/status.py +++ b/roborock/devices/traits/v1/status.py @@ -21,6 +21,7 @@ resolve_cleaning_mode, ) from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener +from roborock.exceptions import RoborockUnsupportedFeature from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand @@ -51,6 +52,7 @@ class StatusTrait(StatusV2, common.V1TraitMixin, TraitUpdateListener): - Fan Speed - Water Mode - Mop Route + - Clean then mop (``clean_then_mop``, independent of the cleaning mode) You should use the _options version of the attribute to know which are supported for your device (i.e. fan_speed_options) @@ -135,10 +137,18 @@ def mop_route_name(self) -> str | None: @property def clean_then_mop(self) -> bool | None: - """Whether the current run vacuums each room fully before mopping it. + """Whether each room is vacuumed fully before it is mopped. - Reported by the device as ``seq_type``. It describes the run in - progress; it is not a persisted setting and cannot be set directly. + Reported by the device as ``seq_type`` and persisted on the device: it + tracks the "vacuum then mop" toggle in the vendor app, also while docked. + + This is a separate axis from ``current_cleaning_mode``. The cleaning + mode is derived from ``fan_power``, ``water_box_mode`` and ``mop_mode`` + and describes *what* the robot does in a room (vacuum, mop, both). This + flag describes the *order* when it does both. Changing one leaves the + other untouched; set it with ``set_clean_then_mop``. + + ``None`` means the device does not support the feature. """ if self.seq_type is None: return None @@ -183,6 +193,27 @@ async def resolve_error(self, error_code: int | None = None) -> None: await self.rpc_channel.send_command(RoborockCommand.RESOLVE_ERROR, params={"error_code": error_code}) await self.refresh() + async def set_clean_then_mop(self, enabled: bool) -> None: + """Set whether each room is vacuumed fully before it is mopped. + + Independent of ``set_cleaning_mode``. The firmware requires the current + motor-mode values in this request, so they are echoed back unchanged. + """ + if not self._device_features_trait.is_clean_then_mop_mode_supported: + raise RoborockUnsupportedFeature("Clean then mop is not supported") + seq_type = _SEQ_TYPE_CLEAN_THEN_MOP if enabled else 0 + params: dict[str, int | None] = { + "type": seq_type, + "fan_power": self.fan_power, + "water_box_mode": self.water_box_mode, + } + if self.mop_mode is not None: + params["mop_mode"] = self.mop_mode + if enabled and self._device_features_trait.is_ctm_with_repeat_supported: + params["repeat"] = 1 + await self.rpc_channel.send_command(RoborockCommand.APP_SET_CLEAN_SEQUENCE_TYPE, params=params) + self.seq_type = seq_type + def update_from_dps(self, decoded_dps: dict[RoborockDataProtocol, Any]) -> None: """Update the trait from data protocol push message data. diff --git a/roborock/roborock_typing.py b/roborock/roborock_typing.py index 37cdf4d9d..a3b376c3a 100644 --- a/roborock/roborock_typing.py +++ b/roborock/roborock_typing.py @@ -34,6 +34,7 @@ class RoborockCommand(str, Enum): APP_SET_CROSS_CARPET_CLEANING_STATUS = "app_set_cross_carpet_cleaning_status" APP_SET_DOOR_SILL_BLOCKS = "app_set_door_sill_blocks" APP_SET_DIRTY_REPLENISH_CLEAN_STATUS = "app_set_dirty_replenish_clean_status" + APP_SET_CLEAN_SEQUENCE_TYPE = "app_set_clean_sequence_type" APP_SET_DRYER_SETTING = "app_set_dryer_setting" APP_SET_DRYER_STATUS = "app_set_dryer_status" APP_SET_DYNAMIC_CONFIG = "app_set_dynamic_config" diff --git a/tests/devices/traits/v1/test_status.py b/tests/devices/traits/v1/test_status.py index 7e52146a5..2df099827 100644 --- a/tests/devices/traits/v1/test_status.py +++ b/tests/devices/traits/v1/test_status.py @@ -939,3 +939,47 @@ def test_status_parses_seq_type() -> None: status = StatusV2.from_dict({**STATUS, "seq_type": 1}) assert status.seq_type == 1 + + +@pytest.mark.parametrize( + ("enabled", "ctm_with_repeat", "expected_params"), + [ + (True, True, {"type": 1, "fan_power": 102, "water_box_mode": 202, "mop_mode": 300, "repeat": 1}), + (True, False, {"type": 1, "fan_power": 102, "water_box_mode": 202, "mop_mode": 300}), + (False, True, {"type": 0, "fan_power": 102, "water_box_mode": 202, "mop_mode": 300}), + ], +) +async def test_set_clean_then_mop( + mock_rpc_channel: AsyncMock, + enabled: bool, + ctm_with_repeat: bool, + expected_params: dict[str, int], +) -> None: + """The setter echoes the current motor modes and updates seq_type optimistically.""" + status_trait = _create_cleaning_mode_status_trait( + is_clean_then_mop_mode_supported=True, + is_ctm_with_repeat_supported=ctm_with_repeat, + ) + status_trait._rpc_channel = mock_rpc_channel # type: ignore[assignment] + status_trait.fan_power = 102 + status_trait.water_box_mode = 202 + status_trait.mop_mode = 300 + status_trait.seq_type = 0 if enabled else 1 + + await status_trait.set_clean_then_mop(enabled) + + mock_rpc_channel.send_command.assert_called_once_with( + RoborockCommand.APP_SET_CLEAN_SEQUENCE_TYPE, params=expected_params + ) + assert status_trait.clean_then_mop is enabled + + +async def test_set_clean_then_mop_unsupported(mock_rpc_channel: AsyncMock) -> None: + """Devices without the feature reject the setter before sending anything.""" + status_trait = _create_cleaning_mode_status_trait(is_clean_then_mop_mode_supported=False) + status_trait._rpc_channel = mock_rpc_channel # type: ignore[assignment] + + with pytest.raises(RoborockUnsupportedFeature): + await status_trait.set_clean_then_mop(True) + + mock_rpc_channel.send_command.assert_not_called() From ee41488b55fd599ceb1c4abbd1d87c142f503c0b Mon Sep 17 00:00:00 2001 From: Felix Svensson Date: Mon, 12 Oct 2026 19:51:02 +0200 Subject: [PATCH 3/4] fix(v1): notify listeners and guard unloaded status in set_clean_then_mop --- roborock/devices/traits/v1/status.py | 18 ++++++++++++++---- tests/devices/traits/v1/test_status.py | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/roborock/devices/traits/v1/status.py b/roborock/devices/traits/v1/status.py index 56136413e..56d49505c 100644 --- a/roborock/devices/traits/v1/status.py +++ b/roborock/devices/traits/v1/status.py @@ -21,7 +21,7 @@ resolve_cleaning_mode, ) from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener -from roborock.exceptions import RoborockUnsupportedFeature +from roborock.exceptions import RoborockException, RoborockUnsupportedFeature from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand @@ -145,8 +145,15 @@ def clean_then_mop(self) -> bool | None: This is a separate axis from ``current_cleaning_mode``. The cleaning mode is derived from ``fan_power``, ``water_box_mode`` and ``mop_mode`` and describes *what* the robot does in a room (vacuum, mop, both). This - flag describes the *order* when it does both. Changing one leaves the - other untouched; set it with ``set_clean_then_mop``. + flag describes the *order* when it does both. On the device the two are + stored independently: writing one leaves the other untouched. Set it + with ``set_clean_then_mop``. + + The vendor app presents it as a fourth choice next to "vacuum and mop", + "mop only" and "vacuum only", and clears it when one of those is picked. + A consumer that wants the same experience should turn this off when it + selects a cleaning mode other than vacuum and mop; this library does + not do that implicitly. ``None`` means the device does not support the feature. """ @@ -201,8 +208,10 @@ async def set_clean_then_mop(self, enabled: bool) -> None: """ if not self._device_features_trait.is_clean_then_mop_mode_supported: raise RoborockUnsupportedFeature("Clean then mop is not supported") + if self.fan_power is None or self.water_box_mode is None: + raise RoborockException("Status is not loaded, call refresh() first") seq_type = _SEQ_TYPE_CLEAN_THEN_MOP if enabled else 0 - params: dict[str, int | None] = { + params: dict[str, int] = { "type": seq_type, "fan_power": self.fan_power, "water_box_mode": self.water_box_mode, @@ -213,6 +222,7 @@ async def set_clean_then_mop(self, enabled: bool) -> None: params["repeat"] = 1 await self.rpc_channel.send_command(RoborockCommand.APP_SET_CLEAN_SEQUENCE_TYPE, params=params) self.seq_type = seq_type + self._notify_update() def update_from_dps(self, decoded_dps: dict[RoborockDataProtocol, Any]) -> None: """Update the trait from data protocol push message data. diff --git a/tests/devices/traits/v1/test_status.py b/tests/devices/traits/v1/test_status.py index 2df099827..53bb146e8 100644 --- a/tests/devices/traits/v1/test_status.py +++ b/tests/devices/traits/v1/test_status.py @@ -966,12 +966,17 @@ async def test_set_clean_then_mop( status_trait.mop_mode = 300 status_trait.seq_type = 0 if enabled else 1 + event = asyncio.Event() + unsubscribe = status_trait.add_update_listener(event.set) + await status_trait.set_clean_then_mop(enabled) mock_rpc_channel.send_command.assert_called_once_with( RoborockCommand.APP_SET_CLEAN_SEQUENCE_TYPE, params=expected_params ) assert status_trait.clean_then_mop is enabled + assert event.is_set() + unsubscribe() async def test_set_clean_then_mop_unsupported(mock_rpc_channel: AsyncMock) -> None: @@ -983,3 +988,14 @@ async def test_set_clean_then_mop_unsupported(mock_rpc_channel: AsyncMock) -> No await status_trait.set_clean_then_mop(True) mock_rpc_channel.send_command.assert_not_called() + + +async def test_set_clean_then_mop_requires_loaded_status(mock_rpc_channel: AsyncMock) -> None: + """The setter needs the current motor modes, so it refuses to run before a refresh.""" + status_trait = _create_cleaning_mode_status_trait(is_clean_then_mop_mode_supported=True) + status_trait._rpc_channel = mock_rpc_channel # type: ignore[assignment] + + with pytest.raises(RoborockException, match="refresh"): + await status_trait.set_clean_then_mop(True) + + mock_rpc_channel.send_command.assert_not_called() From 046d7f6cbdd5ea177d916ca5c5d6aa24356db842 Mon Sep 17 00:00:00 2001 From: Pau Lleonart Calvo Date: Mon, 9 Nov 2026 22:21:28 -0500 Subject: [PATCH 4/4] feat(v1): add CleaningMode.VAC_THEN_MOP Model the app's "Vacuum then mop" choice as a fourth high-level cleaning mode, as suggested in review of #959, so consumers such as Home Assistant get it through cleaning_mode_options / current_cleaning_mode / set_cleaning_mode without orchestrating a separate control. - Offer VAC_THEN_MOP when is_clean_then_mop_mode_supported. - Report it when vacuum-and-mop settings run with seq_type 1. - set_cleaning_mode writes the vacuum-and-mop motor settings, then app_set_clean_sequence_type with type 1 (repeat 1 when is_ctm_with_repeat_supported); switching to another mode while it is enabled sends type 0, like the app. - clean_then_mop returns None on devices without the feature. - Replace the separate set_clean_then_mop setter with the mode, and share SEQ_TYPE_CLEAN_THEN_MOP with the per-room customize clean mode trait. Co-Authored-By: Claude Opus 5.5 --- roborock/data/v1/v1_clean_modes.py | 45 ++++++- roborock/data/v1/v1_containers.py | 5 +- roborock/devices/traits/v1/status.py | 83 +++++-------- tests/devices/traits/v1/test_status.py | 160 ++++++++++++++++++++----- 4 files changed, 205 insertions(+), 88 deletions(-) diff --git a/roborock/data/v1/v1_clean_modes.py b/roborock/data/v1/v1_clean_modes.py index 7d1e1fe05..2e1446a23 100644 --- a/roborock/data/v1/v1_clean_modes.py +++ b/roborock/data/v1/v1_clean_modes.py @@ -84,11 +84,21 @@ class CleaningMode(StrEnum): VACUUM = "vacuum" VAC_AND_MOP = "vac_and_mop" + VAC_THEN_MOP = "vac_then_mop" + """Vacuum first with the mop pads detached, then return for the pads and mop. + + Uses the same motor settings as `VAC_AND_MOP`; the order is a separate + device setting (`seq_type`) that the app writes alongside them. + """ MOP = "mop" CUSTOM = "custom" SMART_MODE = "smart_mode" +# `seq_type` value for "vacuum first, then mop"; 0 means both in one pass. +SEQ_TYPE_CLEAN_THEN_MOP = 1 + + _WATER_SLIDE_MODES: tuple[WaterModes, ...] = ( WaterModes.OFF, WaterModes.PURE_WATER_FLOW_START, @@ -241,6 +251,8 @@ def get_cleaning_mode_options(features: DeviceFeatures) -> list[CleaningMode]: supported_water_modes = get_water_modes(features) options = [CleaningMode.VACUUM, CleaningMode.VAC_AND_MOP] + if features.is_clean_then_mop_mode_supported: + options.append(CleaningMode.VAC_THEN_MOP) if features.is_pure_clean_mop_supported: options.append(CleaningMode.MOP) if features.is_customized_clean_supported and WaterModes.CUSTOMIZED in supported_water_modes: @@ -275,7 +287,7 @@ def _get_clean_motor_mode_params( """Return (fan_power, water_box_mode, mop_mode) enums for the high-level mode.""" if mode == CleaningMode.VACUUM: return (VacuumModes.BALANCED, WaterModes.OFF, CleanRoutes.STANDARD) - if mode == CleaningMode.VAC_AND_MOP: + if mode in (CleaningMode.VAC_AND_MOP, CleaningMode.VAC_THEN_MOP): return (VacuumModes.BALANCED, _get_default_mopping_water_mode(features), CleanRoutes.STANDARD) if mode == CleaningMode.MOP: return ( @@ -312,6 +324,27 @@ def get_cleaning_mode_parameters(cleaning_mode: CleaningMode, features: DeviceFe return [params] +def get_clean_sequence_parameters( + cleaning_mode: CleaningMode, + motor_params: dict[str, int], + features: DeviceFeatures, +) -> dict[str, int]: + """Get the `app_set_clean_sequence_type` payload for a high-level cleaning mode. + + The firmware requires the motor settings (`fan_power`, `water_box_mode` and + `mop_mode`) in the request, so `motor_params` should be the values just sent + with `set_clean_motor_mode`. The app adds `repeat: 1` when enabling the + sequence on devices with `is_ctm_with_repeat_supported`. + """ + if not features.is_clean_then_mop_mode_supported: + raise RoborockUnsupportedFeature("Clean then mop is not supported") + enabled = cleaning_mode == CleaningMode.VAC_THEN_MOP + params = {"type": SEQ_TYPE_CLEAN_THEN_MOP if enabled else 0, **motor_params} + if enabled and features.is_ctm_with_repeat_supported: + params["repeat"] = 1 + return params + + def _resolve_mode_code(value: int | ModeEnumT | None, mode_cls: type[ModeEnumT]) -> ModeEnumT | None: """Resolve a raw code or enum into a RoborockModeEnum.""" if value is None: @@ -346,8 +379,14 @@ def get_current_cleaning_mode( water_mode: int | WaterModes | None, mop_mode: int | CleanRoutes | None, features: DeviceFeatures, + seq_type: int | None = None, ) -> CleaningMode | None: - """Classify the current high-level cleaning mode from individual mode codes.""" + """Classify the current high-level cleaning mode from individual mode codes. + + `seq_type` is the device's cleaning order (`get_status`); when it is + `SEQ_TYPE_CLEAN_THEN_MOP` on a device that supports it, vacuum-and-mop + settings are reported as `CleaningMode.VAC_THEN_MOP`. + """ if not features.is_support_water_mode: return None clean_mode_enum = _resolve_clean_mode(clean_mode, features) @@ -368,6 +407,8 @@ def get_current_cleaning_mode( pass if water_mode_enum == WaterModes.OFF: return CleaningMode.VACUUM + if features.is_clean_then_mop_mode_supported and seq_type == SEQ_TYPE_CLEAN_THEN_MOP: + return CleaningMode.VAC_THEN_MOP return CleaningMode.VAC_AND_MOP diff --git a/roborock/data/v1/v1_containers.py b/roborock/data/v1/v1_containers.py index 97fce2562..4113263a1 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -19,7 +19,7 @@ from roborock.roborock_message import RoborockDataProtocol from ..containers import NamedRoomMapping, RoborockBase, RoborockBaseTimer, _attr_repr, field_metadata -from .v1_clean_modes import WashTowelModes +from .v1_clean_modes import SEQ_TYPE_CLEAN_THEN_MOP, WashTowelModes from .v1_code_mappings import ( CleanFluidStatus, ClearWaterBoxStatus, @@ -517,9 +517,6 @@ class WashTowelMode(RoborockBase): wash_mode: WashTowelModes | None = None -SEQ_TYPE_CLEAN_THEN_MOP = 1 - - @dataclass class SegmentCleanMode(RoborockBase): """A single room's clean mode override, as reported by the device. diff --git a/roborock/devices/traits/v1/status.py b/roborock/devices/traits/v1/status.py index 56d49505c..6cd86335b 100644 --- a/roborock/devices/traits/v1/status.py +++ b/roborock/devices/traits/v1/status.py @@ -3,6 +3,7 @@ from typing import Any from roborock import ( + SEQ_TYPE_CLEAN_THEN_MOP, CleaningMode, CleanRoutes, RoborockDockState, @@ -11,6 +12,7 @@ WaterModes, get_clean_modes, get_clean_routes, + get_clean_sequence_parameters, get_cleaning_mode_options, get_cleaning_mode_parameters, get_current_cleaning_mode, @@ -21,7 +23,6 @@ resolve_cleaning_mode, ) from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener -from roborock.exceptions import RoborockException, RoborockUnsupportedFeature from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand @@ -32,8 +33,6 @@ _DPS_CONVERTER = DpsDataConverter.from_dataclass(StatusV2) -_SEQ_TYPE_CLEAN_THEN_MOP = 1 - class StatusTrait(StatusV2, common.V1TraitMixin, TraitUpdateListener): """Trait for managing the status of Roborock devices. @@ -52,7 +51,7 @@ class StatusTrait(StatusV2, common.V1TraitMixin, TraitUpdateListener): - Fan Speed - Water Mode - Mop Route - - Clean then mop (``clean_then_mop``, independent of the cleaning mode) + - Clean then mop (``clean_then_mop``, reported as ``CleaningMode.VAC_THEN_MOP``) You should use the _options version of the attribute to know which are supported for your device (i.e. fan_speed_options) @@ -140,26 +139,19 @@ def clean_then_mop(self) -> bool | None: """Whether each room is vacuumed fully before it is mopped. Reported by the device as ``seq_type`` and persisted on the device: it - tracks the "vacuum then mop" toggle in the vendor app, also while docked. - - This is a separate axis from ``current_cleaning_mode``. The cleaning - mode is derived from ``fan_power``, ``water_box_mode`` and ``mop_mode`` - and describes *what* the robot does in a room (vacuum, mop, both). This - flag describes the *order* when it does both. On the device the two are - stored independently: writing one leaves the other untouched. Set it - with ``set_clean_then_mop``. - - The vendor app presents it as a fourth choice next to "vacuum and mop", - "mop only" and "vacuum only", and clears it when one of those is picked. - A consumer that wants the same experience should turn this off when it - selects a cleaning mode other than vacuum and mop; this library does - not do that implicitly. - - ``None`` means the device does not support the feature. + tracks the "vacuum then mop" choice in the vendor app, also while docked. + + The device stores it separately from ``fan_power``, ``water_box_mode`` + and ``mop_mode``, but the app presents it as a fourth cleaning mode, so + ``current_cleaning_mode`` reports ``CleaningMode.VAC_THEN_MOP`` when it + is set with vacuum-and-mop settings, and ``set_cleaning_mode`` writes it. + + ``None`` means the device does not support the feature or has not + reported it. """ - if self.seq_type is None: + if not self._device_features_trait.is_clean_then_mop_mode_supported or self.seq_type is None: return None - return self.seq_type == _SEQ_TYPE_CLEAN_THEN_MOP + return self.seq_type == SEQ_TYPE_CLEAN_THEN_MOP @property def current_cleaning_mode(self) -> CleaningMode | None: @@ -168,6 +160,7 @@ def current_cleaning_mode(self) -> CleaningMode | None: water_mode=self.water_box_mode, mop_mode=self.mop_mode, features=self._device_features_trait, + seq_type=self.seq_type, ) @property @@ -177,11 +170,23 @@ def current_cleaning_mode_name(self) -> str | None: return cleaning_mode.value async def set_cleaning_mode(self, cleaning_mode: str | CleaningMode) -> None: - """Set the preferred high-level cleaning mode for the device.""" - await self.rpc_channel.send_command( - RoborockCommand.SET_CLEAN_MOTOR_MODE, - params=get_cleaning_mode_parameters(resolve_cleaning_mode(cleaning_mode), self._device_features_trait), - ) + """Set the preferred high-level cleaning mode for the device. + + Like the app, this also writes the cleaning order on devices that support + clean then mop: it is enabled for ``CleaningMode.VAC_THEN_MOP`` and + cleared when switching to any other mode while it is enabled. + """ + mode = resolve_cleaning_mode(cleaning_mode) + motor_params = get_cleaning_mode_parameters(mode, self._device_features_trait) + await self.rpc_channel.send_command(RoborockCommand.SET_CLEAN_MOTOR_MODE, params=motor_params) + if not self._device_features_trait.is_clean_then_mop_mode_supported: + return + if mode != CleaningMode.VAC_THEN_MOP and not self.clean_then_mop: + return + sequence_params = get_clean_sequence_parameters(mode, motor_params[0], self._device_features_trait) + await self.rpc_channel.send_command(RoborockCommand.APP_SET_CLEAN_SEQUENCE_TYPE, params=sequence_params) + self.seq_type = sequence_params["type"] + self._notify_update() async def resolve_error(self, error_code: int | None = None) -> None: """Resolve an error, like tapping "Resolved" in the Roborock app. @@ -200,30 +205,6 @@ async def resolve_error(self, error_code: int | None = None) -> None: await self.rpc_channel.send_command(RoborockCommand.RESOLVE_ERROR, params={"error_code": error_code}) await self.refresh() - async def set_clean_then_mop(self, enabled: bool) -> None: - """Set whether each room is vacuumed fully before it is mopped. - - Independent of ``set_cleaning_mode``. The firmware requires the current - motor-mode values in this request, so they are echoed back unchanged. - """ - if not self._device_features_trait.is_clean_then_mop_mode_supported: - raise RoborockUnsupportedFeature("Clean then mop is not supported") - if self.fan_power is None or self.water_box_mode is None: - raise RoborockException("Status is not loaded, call refresh() first") - seq_type = _SEQ_TYPE_CLEAN_THEN_MOP if enabled else 0 - params: dict[str, int] = { - "type": seq_type, - "fan_power": self.fan_power, - "water_box_mode": self.water_box_mode, - } - if self.mop_mode is not None: - params["mop_mode"] = self.mop_mode - if enabled and self._device_features_trait.is_ctm_with_repeat_supported: - params["repeat"] = 1 - await self.rpc_channel.send_command(RoborockCommand.APP_SET_CLEAN_SEQUENCE_TYPE, params=params) - self.seq_type = seq_type - self._notify_update() - def update_from_dps(self, decoded_dps: dict[RoborockDataProtocol, Any]) -> None: """Update the trait from data protocol push message data. diff --git a/tests/devices/traits/v1/test_status.py b/tests/devices/traits/v1/test_status.py index 53bb146e8..a0c8e1d83 100644 --- a/tests/devices/traits/v1/test_status.py +++ b/tests/devices/traits/v1/test_status.py @@ -923,13 +923,22 @@ def test_update_listener_ignores_unrelated(status_trait: StatusTrait) -> None: ("seq_type", "expected"), [(1, True), (0, False), (None, None)], ) -def test_clean_then_mop(status_trait: StatusTrait, seq_type: int | None, expected: bool | None) -> None: - """seq_type reports whether the current run vacuums before mopping.""" +def test_clean_then_mop(seq_type: int | None, expected: bool | None) -> None: + """seq_type reports whether the device vacuums before mopping.""" + status_trait = _create_cleaning_mode_status_trait(is_clean_then_mop_mode_supported=True) status_trait.seq_type = seq_type assert status_trait.clean_then_mop is expected +def test_clean_then_mop_unsupported_device() -> None: + """A device without the feature reports None even if it sends seq_type.""" + status_trait = _create_cleaning_mode_status_trait(is_clean_then_mop_mode_supported=False) + status_trait.seq_type = 0 + + assert status_trait.clean_then_mop is None + + def test_status_parses_seq_type() -> None: """seq_type is read from the device status payload. @@ -941,61 +950,150 @@ def test_status_parses_seq_type() -> None: assert status.seq_type == 1 +def test_cleaning_mode_options_with_clean_then_mop() -> None: + """Clean then mop is offered next to vacuum and mop, like the app's mode picker.""" + status_trait = _create_cleaning_mode_status_trait(is_clean_then_mop_mode_supported=True) + + assert status_trait.cleaning_mode_options == [ + CleaningMode.VACUUM, + CleaningMode.VAC_AND_MOP, + CleaningMode.VAC_THEN_MOP, + CleaningMode.MOP, + CleaningMode.CUSTOM, + ] + + @pytest.mark.parametrize( - ("enabled", "ctm_with_repeat", "expected_params"), + ("fan_power", "water_box_mode", "seq_type", "clean_then_mop_supported", "expected_mode"), [ - (True, True, {"type": 1, "fan_power": 102, "water_box_mode": 202, "mop_mode": 300, "repeat": 1}), - (True, False, {"type": 1, "fan_power": 102, "water_box_mode": 202, "mop_mode": 300}), - (False, True, {"type": 0, "fan_power": 102, "water_box_mode": 202, "mop_mode": 300}), + # Qrevo Edge 3 Pro (a296, water slide) after picking "Vacuum then mop" in the app. + (102, 248, 1, True, CleaningMode.VAC_THEN_MOP), + (101, 236, 1, True, CleaningMode.VAC_THEN_MOP), + (102, 248, 0, True, CleaningMode.VAC_AND_MOP), + (102, 248, None, True, CleaningMode.VAC_AND_MOP), + # The order only matters when both vacuuming and mopping. + (102, 200, 1, True, CleaningMode.VACUUM), + (105, 248, 1, True, CleaningMode.MOP), + # Ignored on devices without the feature. + (102, 248, 1, False, CleaningMode.VAC_AND_MOP), ], ) -async def test_set_clean_then_mop( +def test_current_cleaning_mode_clean_then_mop( + fan_power: int, + water_box_mode: int, + seq_type: int | None, + clean_then_mop_supported: bool, + expected_mode: CleaningMode, +) -> None: + """VAC_THEN_MOP is reported when the device runs vacuum-and-mop settings in sequence.""" + status_trait = _create_cleaning_mode_status_trait( + is_water_slide_mode_supported=True, + is_clean_then_mop_mode_supported=clean_then_mop_supported, + ) + status_trait.fan_power = fan_power + status_trait.water_box_mode = water_box_mode + status_trait.mop_mode = CleanRoutes.STANDARD.code + status_trait.seq_type = seq_type + + assert status_trait.current_cleaning_mode == expected_mode + + +@pytest.mark.parametrize( + ("ctm_with_repeat", "expected_sequence"), + [ + (True, {"type": 1, "fan_power": 102, "water_box_mode": 235, "mop_mode": 300, "repeat": 1}), + (False, {"type": 1, "fan_power": 102, "water_box_mode": 235, "mop_mode": 300}), + ], +) +async def test_set_cleaning_mode_vac_then_mop( mock_rpc_channel: AsyncMock, - enabled: bool, ctm_with_repeat: bool, - expected_params: dict[str, int], + expected_sequence: dict[str, int], ) -> None: - """The setter echoes the current motor modes and updates seq_type optimistically.""" + """VAC_THEN_MOP writes vacuum-and-mop motor settings, then enables the sequence.""" status_trait = _create_cleaning_mode_status_trait( + is_water_slide_mode_supported=True, is_clean_then_mop_mode_supported=True, is_ctm_with_repeat_supported=ctm_with_repeat, ) status_trait._rpc_channel = mock_rpc_channel # type: ignore[assignment] - status_trait.fan_power = 102 - status_trait.water_box_mode = 202 - status_trait.mop_mode = 300 - status_trait.seq_type = 0 if enabled else 1 - + status_trait.seq_type = 0 event = asyncio.Event() unsubscribe = status_trait.add_update_listener(event.set) - await status_trait.set_clean_then_mop(enabled) + await status_trait.set_cleaning_mode(CleaningMode.VAC_THEN_MOP) - mock_rpc_channel.send_command.assert_called_once_with( - RoborockCommand.APP_SET_CLEAN_SEQUENCE_TYPE, params=expected_params - ) - assert status_trait.clean_then_mop is enabled + assert mock_rpc_channel.send_command.call_args_list == [ + call( + RoborockCommand.SET_CLEAN_MOTOR_MODE, + params=[{"fan_power": 102, "water_box_mode": 235, "mop_mode": 300}], + ), + call(RoborockCommand.APP_SET_CLEAN_SEQUENCE_TYPE, params=expected_sequence), + ] + assert status_trait.clean_then_mop is True assert event.is_set() unsubscribe() -async def test_set_clean_then_mop_unsupported(mock_rpc_channel: AsyncMock) -> None: - """Devices without the feature reject the setter before sending anything.""" - status_trait = _create_cleaning_mode_status_trait(is_clean_then_mop_mode_supported=False) +@pytest.mark.parametrize( + ("cleaning_mode", "motor_params"), + [ + (CleaningMode.VAC_AND_MOP, {"fan_power": 102, "water_box_mode": 235, "mop_mode": 300}), + (CleaningMode.VACUUM, {"fan_power": 102, "water_box_mode": 200, "mop_mode": 300}), + (CleaningMode.MOP, {"fan_power": 105, "water_box_mode": 235, "mop_mode": 300}), + ], +) +async def test_set_cleaning_mode_clears_clean_then_mop( + mock_rpc_channel: AsyncMock, + cleaning_mode: CleaningMode, + motor_params: dict[str, int], +) -> None: + """Switching away from VAC_THEN_MOP turns the sequence off, like the app does.""" + status_trait = _create_cleaning_mode_status_trait( + is_water_slide_mode_supported=True, + is_clean_then_mop_mode_supported=True, + is_ctm_with_repeat_supported=True, + ) status_trait._rpc_channel = mock_rpc_channel # type: ignore[assignment] + status_trait.seq_type = 1 - with pytest.raises(RoborockUnsupportedFeature): - await status_trait.set_clean_then_mop(True) + await status_trait.set_cleaning_mode(cleaning_mode) - mock_rpc_channel.send_command.assert_not_called() + assert mock_rpc_channel.send_command.call_args_list == [ + call(RoborockCommand.SET_CLEAN_MOTOR_MODE, params=[motor_params]), + call(RoborockCommand.APP_SET_CLEAN_SEQUENCE_TYPE, params={"type": 0, **motor_params}), + ] + assert status_trait.clean_then_mop is False -async def test_set_clean_then_mop_requires_loaded_status(mock_rpc_channel: AsyncMock) -> None: - """The setter needs the current motor modes, so it refuses to run before a refresh.""" - status_trait = _create_cleaning_mode_status_trait(is_clean_then_mop_mode_supported=True) +@pytest.mark.parametrize(("clean_then_mop_supported", "seq_type"), [(True, 0), (True, None), (False, None)]) +async def test_set_cleaning_mode_leaves_sequence_alone( + mock_rpc_channel: AsyncMock, + clean_then_mop_supported: bool, + seq_type: int | None, +) -> None: + """Only the motor modes are written when the sequence is already off or unsupported.""" + status_trait = _create_cleaning_mode_status_trait( + is_water_slide_mode_supported=True, + is_clean_then_mop_mode_supported=clean_then_mop_supported, + ) + status_trait._rpc_channel = mock_rpc_channel # type: ignore[assignment] + status_trait.seq_type = seq_type + + await status_trait.set_cleaning_mode(CleaningMode.VAC_AND_MOP) + + mock_rpc_channel.send_command.assert_called_once_with( + RoborockCommand.SET_CLEAN_MOTOR_MODE, + params=[{"fan_power": 102, "water_box_mode": 235, "mop_mode": 300}], + ) + + +async def test_set_cleaning_mode_vac_then_mop_unsupported(mock_rpc_channel: AsyncMock) -> None: + """Devices without the feature reject VAC_THEN_MOP before sending anything.""" + status_trait = _create_cleaning_mode_status_trait(is_clean_then_mop_mode_supported=False) status_trait._rpc_channel = mock_rpc_channel # type: ignore[assignment] - with pytest.raises(RoborockException, match="refresh"): - await status_trait.set_clean_then_mop(True) + with pytest.raises(RoborockUnsupportedFeature): + await status_trait.set_cleaning_mode(CleaningMode.VAC_THEN_MOP) mock_rpc_channel.send_command.assert_not_called()