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
63 changes: 30 additions & 33 deletions roborock/data/v1/v1_clean_modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,12 @@ class WaterModes(RoborockModeEnum):
SMART_MODE = ("smart_mode", 209)
SMART_WATER = ("smart_water", 210)
PURE_WATER_FLOW_START = ("slight", 221)
PURE_WATER_FLOW_SMALL = ("low", 225)
PURE_WATER_FLOW_SMALL = ("pure_water_flow_small", 225, "low")
PURE_WATER_FLOW_GENTLE = ("gentle", 230)
PURE_WATER_FLOW_MIDDLE = ("medium", 235)
PURE_WATER_FLOW_MIDDLE = ("pure_water_flow_middle", 235, "medium")
PURE_WATER_FLOW_LARGE = ("moderate", 240)
PURE_WATER_SUPER_BEGIN = ("high", 245)
PURE_WATER_FLOW_END = ("extreme", 250)
PURE_WATER_SUPER_BEGIN = ("pure_water_super_begin", 245, "high")
PURE_WATER_FLOW_END = ("pure_water_flow_end", 250, "extreme")


class WashTowelModes(RoborockModeEnum):
Expand All @@ -89,16 +89,16 @@ class CleaningMode(StrEnum):
SMART_MODE = "smart_mode"


WATER_SLIDE_MODE_MAPPING: dict[int, WaterModes] = {
200: WaterModes.OFF,
221: WaterModes.PURE_WATER_FLOW_START,
225: WaterModes.PURE_WATER_FLOW_SMALL,
230: WaterModes.PURE_WATER_FLOW_GENTLE,
235: WaterModes.PURE_WATER_FLOW_MIDDLE,
240: WaterModes.PURE_WATER_FLOW_LARGE,
245: WaterModes.PURE_WATER_SUPER_BEGIN,
250: WaterModes.PURE_WATER_FLOW_END,
}
_WATER_SLIDE_MODES: tuple[WaterModes, ...] = (
WaterModes.OFF,
WaterModes.PURE_WATER_FLOW_START,
WaterModes.PURE_WATER_FLOW_SMALL,
WaterModes.PURE_WATER_FLOW_GENTLE,
WaterModes.PURE_WATER_FLOW_MIDDLE,
WaterModes.PURE_WATER_FLOW_LARGE,
WaterModes.PURE_WATER_SUPER_BEGIN,
WaterModes.PURE_WATER_FLOW_END,
)

# Water slide devices report the raw slider position (1-30) as 220 + position.
# The app groups positions into named ranges, keyed here by their last position.
Expand Down Expand Up @@ -177,7 +177,7 @@ def get_water_modes(features: DeviceFeatures) -> list[WaterModes]:
"""Get the valid water modes for the device - also known as 'water flow' or 'water level'"""
# Water slide mode supports a separate set of water flow codes.
if features.is_water_slide_mode_supported:
return list(WATER_SLIDE_MODE_MAPPING.values())
return list(_WATER_SLIDE_MODES)

supported_modes = [WaterModes.OFF]
if features.is_mop_shake_module_supported:
Expand All @@ -201,7 +201,7 @@ def get_water_modes(features: DeviceFeatures) -> list[WaterModes]:
def get_water_slide_mode_name(code: int) -> str | None:
"""Get the app's range name for a water slide code, or None if out of range."""
mode = _resolve_water_slide_mode(code)
return mode.value if mode is not None else None
return mode.display_name if mode is not None else None


def _resolve_water_slide_mode(code: int) -> WaterModes | None:
Expand All @@ -222,13 +222,10 @@ def _resolve_water_slide_mode(code: int) -> WaterModes | None:
def get_water_mode_mapping(features: DeviceFeatures) -> dict[int, str]:
"""Get water mode mapping by supported feature set.

WaterModes contains aliases for multiple codes that share the same value
string (e.g. low can be 201 or 225). For water slide mode devices we need
explicit code mapping to preserve those slide-specific codes.
Water slide modes share user-facing names with legacy modes (e.g. "low" is
201 or 225), so the mapping uses display_name rather than the enum value.
"""
if features.is_water_slide_mode_supported:
return {code: mode.value for code, mode in WATER_SLIDE_MODE_MAPPING.items()}
return {mode.code: mode.value for mode in get_water_modes(features)}
return {mode.code: mode.display_name for mode in get_water_modes(features)}


def get_cleaning_mode_options(features: DeviceFeatures) -> list[CleaningMode]:
Expand Down Expand Up @@ -309,14 +306,7 @@ def get_cleaning_mode_parameters(cleaning_mode: CleaningMode, features: DeviceFe
raise RoborockUnsupportedFeature(f"Cleaning mode {cleaning_mode.value!r} is not supported")

fan_power, water_box_mode, mop_mode = _get_clean_motor_mode_params(cleaning_mode, features)
water_code = water_box_mode.code
if features.is_water_slide_mode_supported:
# Aliased water enums retain legacy codes; the device mapping preserves
# slide-specific wire values without changing the user-facing names.
water_code = next(
code for code, name in get_water_mode_mapping(features).items() if name == water_box_mode.value
)
params: dict[str, int] = {"fan_power": fan_power.code, "water_box_mode": water_code}
params: dict[str, int] = {"fan_power": fan_power.code, "water_box_mode": water_box_mode.code}
if features.is_clean_route_setting_supported:
params["mop_mode"] = mop_mode.code
return [params]
Expand All @@ -342,6 +332,15 @@ def _resolve_clean_mode(value: int | VacuumModes | None, features: DeviceFeature
return VacuumModes.from_code_optional(value)


def _resolve_water_mode(value: int | WaterModes | None, features: DeviceFeatures) -> WaterModes | None:
"""Resolve a water mode code, mapping raw slider positions on water slide devices."""
if value is None or isinstance(value, WaterModes):
return value
if features.is_water_slide_mode_supported:
return _resolve_water_slide_mode(value) or WaterModes.from_code_optional(value)
return WaterModes.from_code_optional(value)


def get_current_cleaning_mode(
clean_mode: int | VacuumModes | None,
water_mode: int | WaterModes | None,
Expand All @@ -352,9 +351,7 @@ def get_current_cleaning_mode(
if not features.is_support_water_mode:
return None
clean_mode_enum = _resolve_clean_mode(clean_mode, features)
water_mode_enum = _resolve_mode_code(water_mode, WaterModes)
if features.is_water_slide_mode_supported and isinstance(water_mode, int):
water_mode_enum = _resolve_water_slide_mode(water_mode) or water_mode_enum
water_mode_enum = _resolve_water_mode(water_mode, features)
mop_mode_enum = _resolve_mode_code(mop_mode, CleanRoutes)
if clean_mode_enum is None or water_mode_enum is None:
return None
Expand Down
26 changes: 26 additions & 0 deletions tests/conformance/test_enum_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,32 @@ def test_roborock_mode_enum_handles_unknown_code(mode_enum_cls: type[RoborockMod
assert mode_enum_cls.from_code_optional(sentinel) is None


# Legacy RoborockModeEnum classes whose members still share value strings.
# As these enums are remediated, remove them from this baseline.
KNOWN_MODE_ENUMS_WITH_VALUE_ALIASES = {
"roborock.data.b01_q7.b01_q7_code_mappings.B01Fault",
}

_VALUE_ALIAS_XFAIL_MARKS = {
fqn: pytest.mark.xfail(reason=f"{fqn} has members aliased by value", strict=True)
for fqn in KNOWN_MODE_ENUMS_WITH_VALUE_ALIASES
}


@pytest.mark.parametrize("mode_enum_cls", to_pytest_params(_ALL_MODE_ENUMS, marks_by_fqn=_VALUE_ALIAS_XFAIL_MARKS))
def test_roborock_mode_enum_has_no_value_aliases(mode_enum_cls: type[RoborockModeEnum]) -> None:
"""Members sharing a value string collapse into one member and lose their own code.

Modes that need the same user-facing name should use a distinct value and
pass the shared name as display_name instead.
"""
aliases = {name: member.name for name, member in mode_enum_cls.__members__.items() if name != member.name}
assert not aliases, (
f"{mode_enum_cls.__module__}.{mode_enum_cls.__name__} has members aliased by value: {aliases}. "
"Give each member a unique value and use display_name for the shared name."
)


@pytest.mark.parametrize("enum_cls", to_pytest_params(_ALL_CODE_MAPPING_ENUMS))
def test_code_mapping_enums_inherit_resilient_bases(enum_cls: type[enum.Enum]) -> None:
"""Enums in wire code mapping modules must inherit from RoborockEnum or RoborockModeEnum."""
Expand Down
18 changes: 17 additions & 1 deletion tests/devices/traits/v1/test_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,22 @@ def test_current_cleaning_mode_water_code_fallback(
assert status_trait.current_cleaning_mode == expected_mode


@pytest.mark.parametrize(
("code", "expected_mode", "display_name"),
[
(225, WaterModes.PURE_WATER_FLOW_SMALL, "low"),
(235, WaterModes.PURE_WATER_FLOW_MIDDLE, "medium"),
(245, WaterModes.PURE_WATER_SUPER_BEGIN, "high"),
(250, WaterModes.PURE_WATER_FLOW_END, "extreme"),
],
)
def test_water_slide_modes_keep_their_own_codes(code: int, expected_mode: WaterModes, display_name: str) -> None:
"""Water slide modes are distinct from the legacy modes that share their names."""
assert WaterModes.from_code_optional(code) is expected_mode
assert expected_mode.code == code
assert expected_mode.display_name == display_name


@pytest.mark.parametrize("water_mode", [WaterModes.MEDIUM, WaterModes.PURE_WATER_FLOW_MIDDLE])
def test_current_cleaning_mode_water_slide_accepts_enums(water_mode: WaterModes) -> None:
"""Enum inputs remain valid even when they alias a legacy water mode."""
Expand Down Expand Up @@ -778,7 +794,7 @@ def test_water_slide_mode_mapping() -> None:
245: "high",
250: "extreme",
}
assert [mode.value for mode in status_trait.water_mode_options] == [
assert [mode.display_name for mode in status_trait.water_mode_options] == [
"off",
"slight",
"low",
Expand Down