Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions roborock/data/v1/v1_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
44 changes: 44 additions & 0 deletions roborock/devices/traits/v1/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -28,6 +29,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.
Expand All @@ -46,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)
Expand Down Expand Up @@ -111,6 +115,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" 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
return self.seq_type == _SEQ_TYPE_CLEAN_THEN_MOP

@property
def current_cleaning_mode(self) -> CleaningMode | None:
return get_current_cleaning_mode(
Expand Down Expand Up @@ -150,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.

Expand Down
1 change: 1 addition & 0 deletions roborock/roborock_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion tests/devices/__snapshots__/test_v1_device.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -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=[<CleaningMode.VACUUM: 'vacuum'>, <CleaningMode.VAC_AND_MOP: 'vac_and_mop'>, <CleaningMode.MOP: 'mop'>, <CleaningMode.CUSTOM: 'custom'>], clear_water_box_status=None, collision_avoid_status=None, command=<RoborockCommand.GET_STATUS: 'get_status'>, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_cleaning_mode=<CleaningMode.CUSTOM: 'custom'>, 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=<RoborockDockState.full: 'full'>, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=<RoborockErrorCode.none: 0>, 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=[<VacuumModes.QUIET: 'quiet'>, <VacuumModes.BALANCED: 'balanced'>, <VacuumModes.TURBO: 'turbo'>, <VacuumModes.MAX: 'max'>, <VacuumModes.MAX_PLUS: 'max_plus'>, <VacuumModes.OFF: 'off'>, <VacuumModes.CUSTOMIZED: 'custom'>], has_am=None, hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=<RoborockInCleaning.complete: 0>, 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=[<CleanRoutes.STANDARD: 'standard'>, <CleanRoutes.DEEP: 'deep'>, <CleanRoutes.CUSTOMIZED: 'custom'>], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=<RoborockStateCode.charging: 8>, 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=[<WaterModes.OFF: 'off'>, <WaterModes.MILD: 'mild'>, <WaterModes.STANDARD: 'standard'>, <WaterModes.INTENSE: 'intense'>, <WaterModes.CUSTOM: 'custom_water_flow'>, <WaterModes.CUSTOMIZED: 'custom'>], 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=[<CleaningMode.VACUUM: 'vacuum'>, <CleaningMode.VAC_AND_MOP: 'vac_and_mop'>, <CleaningMode.MOP: 'mop'>, <CleaningMode.CUSTOM: 'custom'>], clear_water_box_status=None, collision_avoid_status=None, command=<RoborockCommand.GET_STATUS: 'get_status'>, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_cleaning_mode=<CleaningMode.CUSTOM: 'custom'>, 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=<RoborockDockState.full: 'full'>, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=<RoborockErrorCode.none: 0>, 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=[<VacuumModes.QUIET: 'quiet'>, <VacuumModes.BALANCED: 'balanced'>, <VacuumModes.TURBO: 'turbo'>, <VacuumModes.MAX: 'max'>, <VacuumModes.MAX_PLUS: 'max_plus'>, <VacuumModes.OFF: 'off'>, <VacuumModes.CUSTOMIZED: 'custom'>], has_am=None, hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=<RoborockInCleaning.complete: 0>, 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=[<CleanRoutes.STANDARD: 'standard'>, <CleanRoutes.DEEP: 'deep'>, <CleanRoutes.CUSTOMIZED: 'custom'>], 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=<RoborockStateCode.charging: 8>, 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=[<WaterModes.OFF: 'off'>, <WaterModes.MILD: 'mild'>, <WaterModes.STANDARD: 'standard'>, <WaterModes.INTENSE: 'intense'>, <WaterModes.CUSTOM: 'custom_water_flow'>, <WaterModes.CUSTOMIZED: 'custom'>], water_shortage_status=None)
# ---
# name: test_device_trait_command_parsing[status].1
dict({
Expand Down
67 changes: 67 additions & 0 deletions tests/devices/traits/v1/test_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
RoborockDockErrorCode,
RoborockErrorCode,
RoborockStateCode,
StatusV2,
)
from roborock.device_features import DeviceFeatures
from roborock.devices.device import RoborockDevice
Expand Down Expand Up @@ -679,3 +680,69 @@ 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


@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()