From ea1631177720930c3941dcc0e5d87c55660cf4a1 Mon Sep 17 00:00:00 2001 From: Felix Svensson Date: Tue, 15 Sep 2026 11:02:43 +0200 Subject: [PATCH 1/2] 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 bf1be98ba..9416fefef 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -137,6 +137,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 fa83f603e..5c0102f32 100644 --- a/roborock/devices/traits/v1/status.py +++ b/roborock/devices/traits/v1/status.py @@ -28,6 +28,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. @@ -111,6 +113,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 0e5c3f5c5..14b165982 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_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_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 32c825246..95a52f690 100644 --- a/tests/devices/traits/v1/test_status.py +++ b/tests/devices/traits/v1/test_status.py @@ -20,6 +20,7 @@ RoborockDockErrorCode, RoborockErrorCode, RoborockStateCode, + StatusV2, ) from roborock.device_features import DeviceFeatures from roborock.devices.device import RoborockDevice @@ -679,3 +680,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 ff368355793a5c745ac356ebc0bfb59c7842e469 Mon Sep 17 00:00:00 2001 From: Felix Svensson Date: Tue, 6 Oct 2026 10:40:39 +0200 Subject: [PATCH 2/2] 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 5c0102f32..fd2fd3cbd 100644 --- a/roborock/devices/traits/v1/status.py +++ b/roborock/devices/traits/v1/status.py @@ -18,6 +18,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 @@ -48,6 +49,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) @@ -115,10 +117,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 @@ -163,6 +173,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 95a52f690..c72d58151 100644 --- a/tests/devices/traits/v1/test_status.py +++ b/tests/devices/traits/v1/test_status.py @@ -702,3 +702,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()