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 @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions roborock/devices/traits/v1/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
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

Expand All @@ -31,6 +32,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 @@ -49,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)
Expand Down Expand Up @@ -131,6 +135,32 @@ 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. 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.
"""
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 @@ -170,6 +200,30 @@ 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,
Comment on lines +216 to +217

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might need to guard against fan power or water box mode being none here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 15c0db8: raises RoborockException("Status is not loaded, call refresh() first") when either is None, before anything is sent. params is now dict[str, int]. Test added.

}
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you see if we need self._notify_update() here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Home Assistant's coordinator subscribes to the status trait's update listener, so without it the optimistic value only shows up after the next poll. Added in 15c0db8, with the test asserting a listener fires.

self._notify_update()

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_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=[<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_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=[<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
83 changes: 83 additions & 0 deletions tests/devices/traits/v1/test_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
RoborockDockState,
RoborockErrorCode,
RoborockStateCode,
StatusV2,
)
from roborock.device_features import DeviceFeatures
from roborock.devices.device import RoborockDevice
Expand Down Expand Up @@ -751,3 +752,85 @@ 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

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:
"""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()


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