Skip to content

WaterModes pure-water members collapse into StrEnum aliases, sending water code 202 to water-slide devices (-10005 Param error) #931

Closed

Description

@liborplisek

Summary

Four WaterModes members share a value string with an earlier member. StrEnum de-duplicates by value, so they silently become aliases and carry the earlier member's code.

The visible consequence: _get_default_mopping_water_mode() returns PURE_WATER_FLOW_MIDDLE specifically to avoid code 202 (per its own comment, water-slide devices "don't accept" it), but the alias resolves to MEDIUM, so set_cleaning_mode() sends 202 to exactly those devices and they reject the command.

Version

python-roborock==7.1.1 (current release, and the version Home Assistant pins). Reproduced on a clean install, CPython 3.13.

Reproduction

from roborock.data.v1.v1_clean_modes import WaterModes, WATER_SLIDE_MODE_MAPPING

print(len(list(WaterModes)))                                   # 15, though 19 names are declared
print(WaterModes.PURE_WATER_FLOW_MIDDLE is WaterModes.MEDIUM)  # True
print(WaterModes.PURE_WATER_FLOW_MIDDLE.code)                  # 202, expected 235
print(WaterModes.from_code_optional(235))                      # None

for code, mode in WATER_SLIDE_MODE_MAPPING.items():
    if code != mode.code:
        print(code, "->", mode.name, mode.code)
# 225 -> LOW 201
# 235 -> MEDIUM 202
# 248 -> HIGH 203
# 250 -> EXTREME 208

The four collapsing pairs are ("low", 225) into LOW, ("medium", 235) into MEDIUM, ("high", 248) into HIGH, and ("extreme", 250) into EXTREME.

Impact

On a device with is_water_slide_mode_supported, get_cleaning_mode_parameters() emits {'fan_power': 105, 'water_box_mode': 202, 'mop_mode': 300} for CleaningMode.MOP (and the same 202 for VAC_AND_MOP). Observed on a Saros 20 Sonic (roborock.vacuum.a288) through the Home Assistant integration:

File ".../roborock/devices/traits/v1/status.py", line 131, in set_cleaning_mode
    await self.rpc_channel.send_command(
roborock.exceptions.RoborockException: {'code': -10005, 'message': 'Param error'}

Sending the same command with 235 instead of 202 succeeds on that device.

Secondary: from_code_optional() returns None for 225, 235, 248 and 250, so those device codes cannot be decoded through the enum.

The display path is unaffected, because get_water_mode_mapping() keys off the dict rather than the member. This only surfaces on writes.

Suggested fix

Unique values plus display_name, matching the existing DEEP_PLUS_CN = ("deep_plus_cn", 305, "deep_plus") precedent:

-    PURE_WATER_FLOW_SMALL = ("low", 225)
-    PURE_WATER_FLOW_MIDDLE = ("medium", 235)
-    PURE_WATER_SUPER_BEGIN = ("high", 248)
-    PURE_WATER_FLOW_END = ("extreme", 250)
+    PURE_WATER_FLOW_SMALL = ("pure_water_flow_small", 225, "low")
+    PURE_WATER_FLOW_MIDDLE = ("pure_water_flow_middle", 235, "medium")
+    PURE_WATER_SUPER_BEGIN = ("pure_water_super_begin", 248, "high")
+    PURE_WATER_FLOW_END = ("pure_water_flow_end", 250, "extreme")

and in get_water_mode_mapping(), read mode.display_name instead of mode.value in both branches.

Verified with that patch applied to 7.1.1: 19 members, all four codes correct, from_code_optional() resolves every slide code, WATER_SLIDE_MODE_MAPPING round-trips, and get_water_mode_mapping() still returns {200: 'off', 221: 'slight', 225: 'low', 235: 'medium', 245: 'moderate', 248: 'high', 250: 'extreme'}, so the user-facing strings do not change.

One follow-up outside this repo: Home Assistant's select.py builds the water options from mode.value and would need mode.display_name, as its mop-route select already does.

Happy to open a PR.

Activity

  1. liborplisek commented on Aug 28, 2026

    @liborplisek
    Author

    Cross-reference: #930 reports the user-visible side of this on a Saros 10R (roborock.vacuum.a144), independently and with iOS app packet captures showing the correct values. Same water_box_mode: 202 and the same -10005 Param error, so this is not specific to one model.

    That report also describes the cleaning-mode entity going unknown after a manually corrected command. That is the same root cause: get_current_cleaning_mode() resolves the reported water code through WaterModes.from_code_optional(), which returns None for 225, 235, 248 and 250, so the classification returns None.

    Verified against a clean install of 7.1.1, CPython 3.13:

    >>> get_cleaning_mode_parameters(CleaningMode.MOP, features)      # water-slide device
    [{'fan_power': 105, 'water_box_mode': 202, 'mop_mode': 300}]
    >>> get_current_cleaning_mode(clean_mode=105, water_mode=225, mop_mode=300, features=features)
    None

    So the patch above fixes both the rejected write and the unresolvable state.

  2. xiaoyaner0201 commented on Sep 16, 2026

    @xiaoyaner0201
    Contributor

    Follow-up implementation: #961

    Confirmed the same combined-mode read/write problem on a G30S Ultra with Home Assistant 2026.9.1 and python-roborock 7.2.3. Thank you for the diagnosis above; I am adding evidence here rather than filing a duplicate.

    The device reported fan_power=102, water_box_mode=235, mop_mode=303. The individual settings correctly read balanced/medium/deep_plus, but current_cleaning_mode was None (HA unknown). This stopped a scheduled automation at its mode-readback gate before vacuum.start. The generated combined-mode command also contained the incorrect water code 202.

    Using the existing feature-aware water mapping for both combined-mode command encoding and status classification restored the mode to vac_and_mop. The real automation then passed its setting gate and started cleaning; independent device readback confirmed the expected motor settings, no error, and increasing activity counters.

    I also found that the existing water-slide tests expected WaterModes.PURE_WATER_FLOW_MIDDLE.code, so they passed with the same erroneous 202 as the implementation. Replacing those expectations with actual wire integers and adding public-trait regressions produces 19 assertion failures before the fix and 79 passing status tests afterward.

    The linked compatibility-focused PR preserves enum/UI names and existing legacy-code fallback. It does not claim that the standalone enum aliases themselves are fixed; your proposed unique-value/display-name migration is a separate, broader change. It also does not change the slider ranges discussed in #801.

  3. koltuknil commented on Sep 27, 2026

    @koltuknil

    Adding evidence from a Saros 20 Sonic (roborock.vacuum.a279), in case it helps with #961. Two things differ from the reports above: this model accepts the wrong code instead of rejecting it, and we saw a possible side effect.

    Environment: Home Assistant 2026.9.3, which pins python-roborock 7.4.2. The robot's water select offers off, slight, low, medium, moderate, high, extreme, so it uses the water-slide mapping.

    Still present in 7.12.0

    The reproduction above gives the same result against the library's own source at both 7.4.2 and 7.12.0. We loaded v1_clean_modes.py and code_mappings.py directly and used a stand-in object with the water-slide feature flags:

    15 members, 19 names
    PURE_WATER_FLOW_MIDDLE is MEDIUM: True
    PURE_WATER_FLOW_MIDDLE.code: 202
    from_code_optional(225), (235), (248), (250) -> None; 200, 221 and 245 resolve
    vac_and_mop -> [{'fan_power': 102, 'water_box_mode': 202, 'mop_mode': 300}]
    mop         -> [{'fan_power': 105, 'water_box_mode': 202, 'mop_mode': 300}]
    get_current_cleaning_mode(fan 102, water 200 / 221 / 245, route 300) -> vacuum / vac_and_mop / vac_and_mop
    get_current_cleaning_mode(fan 102, water 225 / 235 / 248 / 250, route 300) -> None
    

    This model accepts water code 202

    Unlike the a288 above and the Saros 10R in #930, this robot does not reject set_clean_motor_mode with water_box_mode: 202.

    • The service call succeeds and the settings change.
    • Afterwards Home Assistant reads water medium, which it can only decode from 235, so the robot appears to translate 202 itself.
    • The app shows the medium level, 15 on its 1–30 water-flow slider.

    So on some water-slide models the wrong write is silent rather than an error. (The code 202 comes from reading the source and the reproduction above; we did not capture the traffic.)

    Read path, observed through Home Assistant

    The robot was docked, and we made one change at a time through the cleaning-mode select, reading the states and the app after each.

    Step Suction / water / route afterwards Cleaning-mode select App
    Before (set in the app) max / extreme / deep_plus unknown vac & mop
    Select vac_and_mop balanced / medium / standard unknown vac & mop
    Select vacuum balanced / off / standard vacuum vacuum
    Select mop off / medium / standard unknown mop

    This matches from_code_optional() returning None for 235 and 250: the select reads correctly only when the water code resolves.

    Possible side effect of selecting mop (not proven)

    • After selecting mop through Home Assistant, the app's scrub-intensity setting showed no level selected. This was in mop mode, and afterwards also in vac & mop mode, until the level was set again in the app.
    • Selecting vac_and_mop earlier, with the same water code 202, left scrub intensity unchanged.
    • The mode command has no scrub-intensity field. Per the captures in Saros 10R: set_clean_motor_mode generates invalid Mop payload (-10005 Param error) #930, neither does the official app's command, so the missing field alone doesn't explain it.
    • One candidate is that the robot reads 202 differently in mop mode, but we haven't confirmed that.

    We haven't tested #961 yet, but we're happy to try it on this model if that helps.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions