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 65bcba64a..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, @@ -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: @@ -515,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 01eed9a55..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, @@ -49,6 +51,7 @@ class StatusTrait(StatusV2, common.V1TraitMixin, TraitUpdateListener): - Fan Speed - Water Mode - Mop Route + - 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) @@ -131,6 +134,25 @@ 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 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" 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 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 + @property def current_cleaning_mode(self) -> CleaningMode | None: return get_current_cleaning_mode( @@ -138,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 @@ -147,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. 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/__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..a0c8e1d83 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,183 @@ 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(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. + + 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 + + +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( + ("fan_power", "water_box_mode", "seq_type", "clean_then_mop_supported", "expected_mode"), + [ + # 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), + ], +) +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, + ctm_with_repeat: bool, + expected_sequence: dict[str, int], +) -> None: + """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.seq_type = 0 + event = asyncio.Event() + unsubscribe = status_trait.add_update_listener(event.set) + + await status_trait.set_cleaning_mode(CleaningMode.VAC_THEN_MOP) + + 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() + + +@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 + + await status_trait.set_cleaning_mode(cleaning_mode) + + 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 + + +@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(RoborockUnsupportedFeature): + await status_trait.set_cleaning_mode(CleaningMode.VAC_THEN_MOP) + + mock_rpc_channel.send_command.assert_not_called()