Skip to content

Commit 9474bfa

Browse files
authored
feat: add better cleaning mode support (#817)
* feat: add better cleaning mode support * chore: address comments from copilot * chore: address comments from copilot * chore: address comments from copilot * chore: address comments * chore: drop int typing and improve comment * chore: add some comments that better explain 'get_mop_only_vacuum_mode'
1 parent 7888a6d commit 9474bfa

4 files changed

Lines changed: 503 additions & 8 deletions

File tree

roborock/data/v1/v1_clean_modes.py

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from __future__ import annotations
22

33
import typing
4+
from enum import StrEnum
5+
from typing import TypeVar
46

7+
from ...exceptions import RoborockUnsupportedFeature
58
from ..code_mappings import RoborockModeEnum
69

710
if typing.TYPE_CHECKING:
@@ -68,6 +71,22 @@ class WashTowelModes(RoborockModeEnum):
6871
SUPER_DEEP = ("super_deep", 8)
6972

7073

74+
class CleaningMode(StrEnum):
75+
"""High-level cleaning intent derived from the lower-level motor settings.
76+
77+
Prefer this abstraction when you want to present or switch between the
78+
user-facing cleaning behaviors exposed by the app. The lower-level
79+
`VacuumModes`, `WaterModes`, and `CleanRoutes` enums are still useful for
80+
fine-grained selection.
81+
"""
82+
83+
VACUUM = "vacuum"
84+
VAC_AND_MOP = "vac_and_mop"
85+
MOP = "mop"
86+
CUSTOM = "custom"
87+
SMART_MODE = "smart_mode"
88+
89+
7190
WATER_SLIDE_MODE_MAPPING: dict[int, WaterModes] = {
7291
200: WaterModes.OFF,
7392
221: WaterModes.PURE_WATER_FLOW_START,
@@ -78,6 +97,8 @@ class WashTowelModes(RoborockModeEnum):
7897
250: WaterModes.PURE_WATER_FLOW_END,
7998
}
8099

100+
ModeEnumT = TypeVar("ModeEnumT", bound=RoborockModeEnum)
101+
81102

82103
def get_wash_towel_modes(features: DeviceFeatures) -> list[WashTowelModes]:
83104
"""Get the valid wash towel modes for the device"""
@@ -174,7 +195,151 @@ def get_water_mode_mapping(features: DeviceFeatures) -> dict[int, str]:
174195
return {mode.code: mode.value for mode in get_water_modes(features)}
175196

176197

177-
def is_mode_customized(clean_mode: VacuumModes, water_mode: WaterModes, mop_mode: CleanRoutes) -> bool:
198+
def get_cleaning_mode_options(features: DeviceFeatures) -> list[CleaningMode]:
199+
"""Return the supported high-level cleaning modes for the device.
200+
201+
These options are the preferred user-facing choices because they bundle the
202+
correct fan, water, and mop-route settings together for the device. Callers
203+
should generally present these instead of mixing lower-level mode enums
204+
unless they explicitly need fine-grained control.
205+
"""
206+
if not features.is_support_water_mode:
207+
return []
208+
209+
supported_water_modes = get_water_modes(features)
210+
options = [CleaningMode.VACUUM, CleaningMode.VAC_AND_MOP]
211+
if features.is_pure_clean_mop_supported:
212+
options.append(CleaningMode.MOP)
213+
if features.is_customized_clean_supported and WaterModes.CUSTOMIZED in supported_water_modes:
214+
options.append(CleaningMode.CUSTOM)
215+
if features.is_smart_clean_mode_set_supported and WaterModes.SMART_MODE in supported_water_modes:
216+
options.append(CleaningMode.SMART_MODE)
217+
return options
218+
219+
220+
def get_mop_only_vacuum_mode(features: DeviceFeatures) -> VacuumModes:
221+
"""Determine the vacuum mode to use when you just want to mop.
222+
223+
There are three cases that must be handled:
224+
1. The device does not support only mopping.
225+
2. The device supports raising the vacuum brush while mopping
226+
3. All other cases.
227+
"""
228+
if not features.is_pure_clean_mop_supported:
229+
raise RoborockUnsupportedFeature("Mop-only cleaning is not supported")
230+
if features.is_support_main_brush_up_down_supported:
231+
return VacuumModes.OFF_RAISE_MAIN_BRUSH
232+
return VacuumModes.OFF
233+
234+
235+
def _get_default_mopping_water_mode(features: DeviceFeatures) -> WaterModes:
236+
"""Pick a sensible default water mode when mopping for the device."""
237+
# Water-slide devices use a disjoint set of water codes; pick a mid-flow
238+
# slide code instead of the standard 202, which they don't accept.
239+
if features.is_water_slide_mode_supported:
240+
return WaterModes.PURE_WATER_FLOW_MIDDLE
241+
return WaterModes.STANDARD
242+
243+
244+
def _get_clean_motor_mode_params(
245+
mode: CleaningMode,
246+
features: DeviceFeatures,
247+
) -> tuple[VacuumModes, WaterModes, CleanRoutes]:
248+
"""Return (fan_power, water_box_mode, mop_mode) enums for the high-level mode."""
249+
if mode == CleaningMode.VACUUM:
250+
return (VacuumModes.BALANCED, WaterModes.OFF, CleanRoutes.STANDARD)
251+
if mode == CleaningMode.VAC_AND_MOP:
252+
return (VacuumModes.BALANCED, _get_default_mopping_water_mode(features), CleanRoutes.STANDARD)
253+
if mode == CleaningMode.MOP:
254+
return (
255+
get_mop_only_vacuum_mode(features),
256+
_get_default_mopping_water_mode(features),
257+
CleanRoutes.STANDARD,
258+
)
259+
if mode == CleaningMode.CUSTOM:
260+
return (VacuumModes.CUSTOMIZED, WaterModes.CUSTOMIZED, CleanRoutes.CUSTOMIZED)
261+
if mode == CleaningMode.SMART_MODE:
262+
return (VacuumModes.SMART_MODE, WaterModes.SMART_MODE, CleanRoutes.SMART_MODE)
263+
raise RoborockUnsupportedFeature(f"Cleaning mode {mode.value!r} is not supported")
264+
265+
266+
def resolve_cleaning_mode(cleaning_mode: str | CleaningMode) -> CleaningMode:
267+
"""Resolve a string or enum into a CleaningMode value."""
268+
if isinstance(cleaning_mode, CleaningMode):
269+
return cleaning_mode
270+
try:
271+
return CleaningMode(cleaning_mode)
272+
except ValueError as err:
273+
raise RoborockUnsupportedFeature(f"Cleaning mode {cleaning_mode!r} is not supported") from err
274+
275+
276+
def get_cleaning_mode_parameters(cleaning_mode: CleaningMode, features: DeviceFeatures) -> list[dict[str, int]]:
277+
"""Get the RPC payload for switching the high-level cleaning mode."""
278+
if cleaning_mode not in get_cleaning_mode_options(features):
279+
raise RoborockUnsupportedFeature(f"Cleaning mode {cleaning_mode.value!r} is not supported")
280+
281+
fan_power, water_box_mode, mop_mode = _get_clean_motor_mode_params(cleaning_mode, features)
282+
params: dict[str, int] = {"fan_power": fan_power.code, "water_box_mode": water_box_mode.code}
283+
if features.is_clean_route_setting_supported:
284+
params["mop_mode"] = mop_mode.code
285+
return [params]
286+
287+
288+
def _resolve_mode_code(value: int | ModeEnumT | None, mode_cls: type[ModeEnumT]) -> ModeEnumT | None:
289+
"""Resolve a raw code or enum into a RoborockModeEnum."""
290+
if value is None:
291+
return None
292+
if isinstance(value, mode_cls):
293+
return value
294+
return mode_cls.from_code_optional(int(value))
295+
296+
297+
def _resolve_clean_mode(value: int | VacuumModes | None, features: DeviceFeatures) -> VacuumModes | None:
298+
"""Resolve a vacuum mode code, accounting for feature-specific code aliases."""
299+
if value is None or isinstance(value, VacuumModes):
300+
return value
301+
if value == VacuumModes.OFF.code:
302+
if features.is_pure_clean_mop_supported:
303+
return get_mop_only_vacuum_mode(features)
304+
return VacuumModes.GENTLE
305+
return VacuumModes.from_code_optional(value)
306+
307+
308+
def get_current_cleaning_mode(
309+
clean_mode: int | VacuumModes | None,
310+
water_mode: int | WaterModes | None,
311+
mop_mode: int | CleanRoutes | None,
312+
features: DeviceFeatures,
313+
) -> CleaningMode | None:
314+
"""Classify the current high-level cleaning mode from individual mode codes."""
315+
if not features.is_support_water_mode:
316+
return None
317+
clean_mode_enum = _resolve_clean_mode(clean_mode, features)
318+
water_mode_enum = _resolve_mode_code(water_mode, WaterModes)
319+
mop_mode_enum = _resolve_mode_code(mop_mode, CleanRoutes)
320+
if clean_mode_enum is None or water_mode_enum is None:
321+
return None
322+
323+
if is_smart_mode_set(water_mode_enum, clean_mode_enum, mop_mode_enum):
324+
return CleaningMode.SMART_MODE
325+
if is_mode_customized(clean_mode_enum, water_mode_enum, mop_mode_enum):
326+
return CleaningMode.CUSTOM
327+
if water_mode_enum != WaterModes.OFF:
328+
try:
329+
if clean_mode_enum == get_mop_only_vacuum_mode(features):
330+
return CleaningMode.MOP
331+
except RoborockUnsupportedFeature:
332+
pass
333+
if water_mode_enum == WaterModes.OFF:
334+
return CleaningMode.VACUUM
335+
return CleaningMode.VAC_AND_MOP
336+
337+
338+
def is_mode_customized(
339+
clean_mode: VacuumModes | None,
340+
water_mode: WaterModes | None,
341+
mop_mode: CleanRoutes | None,
342+
) -> bool:
178343
"""Check if any of the cleaning modes are set to a custom value."""
179344
return (
180345
clean_mode == VacuumModes.CUSTOMIZED
@@ -183,7 +348,11 @@ def is_mode_customized(clean_mode: VacuumModes, water_mode: WaterModes, mop_mode
183348
)
184349

185350

186-
def is_smart_mode_set(water_mode: WaterModes, clean_mode: VacuumModes, mop_mode: CleanRoutes) -> bool:
351+
def is_smart_mode_set(
352+
water_mode: WaterModes | None,
353+
clean_mode: VacuumModes | None,
354+
mop_mode: CleanRoutes | None,
355+
) -> bool:
187356
"""Check if the smart mode is set for the given water mode and clean mode"""
188357
return (
189358
water_mode == WaterModes.SMART_MODE

roborock/devices/traits/v1/status.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@
33
from typing import Any
44

55
from roborock import (
6+
CleaningMode,
67
CleanRoutes,
78
StatusV2,
89
VacuumModes,
910
WaterModes,
1011
get_clean_modes,
1112
get_clean_routes,
13+
get_cleaning_mode_options,
14+
get_cleaning_mode_parameters,
15+
get_current_cleaning_mode,
1216
get_water_mode_mapping,
1317
get_water_modes,
18+
resolve_cleaning_mode,
1419
)
1520
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
1621
from roborock.roborock_message import RoborockDataProtocol
@@ -42,10 +47,11 @@ class StatusTrait(StatusV2, common.V1TraitMixin, TraitUpdateListener):
4247
- Water Mode
4348
- Mop Route
4449
45-
You should call the _options() version of the attribute to know which are supported for your device
46-
(i.e. fan_speed_options())
47-
Then you can call the _mapping to convert an int value to the actual Enum. (i.e. fan_speed_mapping())
48-
You can call the _name property to get the str value of the enum. (i.e. fan_speed_name)
50+
You should use the _options version of the attribute to know which are
51+
supported for your device (i.e. fan_speed_options)
52+
Then you can use the _mapping to convert an int value to the actual Enum.
53+
(i.e. fan_speed_mapping)
54+
You can use the _name property to get the str value of the enum. (i.e. fan_speed_name)
4955
5056
"""
5157

@@ -83,6 +89,10 @@ def mop_route_options(self) -> list[CleanRoutes]:
8389
def mop_route_mapping(self) -> dict[int, str]:
8490
return {route.code: route.value for route in self.mop_route_options}
8591

92+
@cached_property
93+
def cleaning_mode_options(self) -> list[CleaningMode]:
94+
return get_cleaning_mode_options(self._device_features_trait)
95+
8696
@property
8797
def fan_speed_name(self) -> str | None:
8898
if self.fan_power is None:
@@ -101,6 +111,28 @@ def mop_route_name(self) -> str | None:
101111
return None
102112
return self.mop_route_mapping.get(self.mop_mode)
103113

114+
@property
115+
def current_cleaning_mode(self) -> CleaningMode | None:
116+
return get_current_cleaning_mode(
117+
clean_mode=self.fan_power,
118+
water_mode=self.water_box_mode,
119+
mop_mode=self.mop_mode,
120+
features=self._device_features_trait,
121+
)
122+
123+
@property
124+
def current_cleaning_mode_name(self) -> str | None:
125+
if (cleaning_mode := self.current_cleaning_mode) is None:
126+
return None
127+
return cleaning_mode.value
128+
129+
async def set_cleaning_mode(self, cleaning_mode: str | CleaningMode) -> None:
130+
"""Set the preferred high-level cleaning mode for the device."""
131+
await self.rpc_channel.send_command(
132+
RoborockCommand.SET_CLEAN_MOTOR_MODE,
133+
params=get_cleaning_mode_parameters(resolve_cleaning_mode(cleaning_mode), self._device_features_trait),
134+
)
135+
104136
def update_from_dps(self, decoded_dps: dict[RoborockDataProtocol, Any]) -> None:
105137
"""Update the trait from data protocol push message data.
106138

tests/devices/__snapshots__/test_v1_device.ambr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -870,7 +870,7 @@
870870
})
871871
# ---
872872
# name: test_device_trait_command_parsing[status]
873-
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, 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_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'>], 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)
873+
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'>], 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)
874874
# ---
875875
# name: test_device_trait_command_parsing[status].1
876876
dict({

0 commit comments

Comments
 (0)