Skip to content

Refactor WaterModes to use display_name instead of StrEnum value aliases #987

Open

Description

@allenporter

Problem

RoborockModeEnum subclasses StrEnum and takes (value: str, code: int, display_name: str | None = None). Because StrEnum deduplicates members strictly by their first argument (value: str) and ignores code, any two members in the same enum that share a value string silently collapse into a single enum member and discard the second member's code.

In WaterModes (roborock/data/v1/v1_clean_modes.py), four water-slide members reuse the same value strings as earlier legacy members:

class WaterModes(RoborockModeEnum):
    LOW = ("low", 201)
    MEDIUM = ("medium", 202)
    HIGH = ("high", 203)
    EXTREME = ("extreme", 208)
    ...
    PURE_WATER_FLOW_SMALL = ("low", 225)       # Collapses into LOW (code=201)
    PURE_WATER_FLOW_MIDDLE = ("medium", 235)   # Collapses into MEDIUM (code=202)
    PURE_WATER_SUPER_BEGIN = ("high", 245)     # Collapses into HIGH (code=203)
    PURE_WATER_FLOW_END = ("extreme", 250)     # Collapses into EXTREME (code=208)

At runtime:

  • WaterModes.PURE_WATER_FLOW_MIDDLE is WaterModes.MEDIUM is True
  • WaterModes.PURE_WATER_FLOW_MIDDLE.code is 202 (instead of 235)
  • WaterModes.from_code_optional(235) returns None (same for 225, 245, 250)

Because the enum members lose their own .code values, v1_clean_modes.py has had to accumulate workarounds (#777, #961):

  1. Maintaining a separate WATER_SLIDE_MODE_MAPPING: dict[int, WaterModes] side-table just to preserve the integer codes 225, 235, 245, and 250.
  2. In get_cleaning_mode_parameters(), ignoring water_box_mode.code on water-slide devices and doing a reverse lookup over get_water_mode_mapping(features) by water_box_mode.value (next(code for code, name in ... if name == water_box_mode.value)).
  3. In get_current_cleaning_mode(), calling _resolve_mode_code(water_mode, WaterModes) and then patching water_mode_enum inline with if features.is_water_slide_mode_supported and isinstance(water_mode, int):.

Suggested Fix

1. Use display_name on WaterModes (matching CleanRoutes from #918)

RoborockModeEnum already supports a 3rd display_name parameter specifically for modes that have distinct wire codes but share a canonical user-facing name (e.g. CleanRoutes.DEEP_PLUS_CN = ("deep_plus_cn", 305, "deep_plus")):

class WaterModes(RoborockModeEnum):
    ...
    PURE_WATER_FLOW_START = ("slight", 221)
    PURE_WATER_FLOW_SMALL = ("pure_water_flow_small", 225, "low")
    PURE_WATER_FLOW_GENTLE = ("gentle", 230)
    PURE_WATER_FLOW_MIDDLE = ("pure_water_flow_middle", 235, "medium")
    PURE_WATER_FLOW_LARGE = ("moderate", 240)
    PURE_WATER_SUPER_BEGIN = ("pure_water_super_begin", 245, "high")
    PURE_WATER_FLOW_END = ("pure_water_flow_end", 250, "extreme")

With this change:

  • Every WaterModes member is a distinct enum entry with its own .code (WaterModes.PURE_WATER_FLOW_MIDDLE.code == 235).
  • WaterModes.PURE_WATER_FLOW_MIDDLE.display_name == "medium".
  • WaterModes.from_code_optional(235) returns WaterModes.PURE_WATER_FLOW_MIDDLE.

2. Simplify v1_clean_modes.py

  • get_water_mode_mapping(features): Use mode.display_name uniformly across all devices (matching StatusTrait.mop_route_mapping):
    def get_water_mode_mapping(features: DeviceFeatures) -> dict[int, str]:
        return {mode.code: mode.display_name for mode in get_water_modes(features)}
  • get_water_slide_mode_name(code): Return mode.display_name if mode is not None else None.
  • get_cleaning_mode_parameters(cleaning_mode, features): Remove the if features.is_water_slide_mode_supported: water_code = next(...) workaround and use water_box_mode.code directly.
  • get_current_cleaning_mode(...): Extract _resolve_water_mode(value, features) parallel to _resolve_clean_mode(value, features) instead of checking isinstance(water_mode, int) inline.

3. Guard against future silent RoborockModeEnum alias collisions

Add a check in RoborockModeEnum.__new__ (or in tests/conformance/test_enum_conformance.py) that fails if two members in a RoborockModeEnum subclass share the same value string with different code integers instead of using display_name.


Proposed Upgrade Plan

Downstream in Home Assistant Core (homeassistant/components/roborock/select.py), mop_mode already uses mode.display_name, but water_box_mode still reads mode.value:

    RoborockSelectDescription(
        key="water_box_mode",
        translation_key="mop_intensity",
        api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE,
        value_fn=lambda api: api.status.water_mode_name,
        entity_category=EntityCategory.CONFIG,
        options_lambda=lambda api: (
            [mode.value for mode in api.status.water_mode_options]  # <-- uses .value instead of .display_name
            if api.status.water_mode_options
            else None
        ),
        ...
    ),

To make this migration seamless and non-breaking:

  1. Step 1 (Home Assistant Core): Update water_box_mode in homeassistant/components/roborock/select.py from [mode.value for mode in api.status.water_mode_options] to [mode.display_name for mode in api.status.water_mode_options]. Because RoborockModeEnum.display_name falls back to self.value when _display_name_ is None, this is a no-op with python-roborock v7.12.4 and can be merged independently (or alongside the dependency bump).
  2. Step 2 (python-roborock): Land the WaterModes display_name fix, remove the workarounds in v1_clean_modes.py, and add the enum conformance check.
  3. Step 3 (Home Assistant Core): Bump python-roborock in Home Assistant Core. User-facing option strings ("off", "slight", "low", "gentle", "medium", "moderate", "high", "extreme") and water_mode_mapping remain unchanged.

Activity

  1. solsxia commented on Oct 17, 2026

    @solsxia

    Hi! I'm interested in this issue and would like to work on it. Could you assign it to me? Thank you.

  2. rohitpatil9121 commented on Oct 19, 2026

    @rohitpatil9121

    Hi! I've opened a draft PR for this: #993. It follows the approach in the issue description: unique values with display_name for the water slide modes, the workarounds removed, and a conformance check for value aliases.

    @solsxia sorry, I only saw your comment after I'd started. Happy to coordinate if you were already working on this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions