From 0e7b014f304695e34c6a5708acd27225ebf756d9 Mon Sep 17 00:00:00 2001 From: Pau Lleonart Calvo Date: Mon, 9 Nov 2026 22:24:12 -0500 Subject: [PATCH] fix(v1): offer custom and SmartPlan cleaning modes on water-slide devices Water-slide devices report flow as slider positions, so get_water_modes() has no CUSTOMIZED (204) or SMART_MODE (209) entry for them and get_cleaning_mode_options() dropped CUSTOM and SMART_MODE even when the feature flags are set. get_current_cleaning_mode() still classifies those states, so consumers such as Home Assistant showed an unknown cleaning mode whenever the device was in custom or SmartPlan mode. Gate both options on the feature flags alone for water-slide devices. Verified on a Qrevo Edge 3 Pro (roborock.vacuum.a296): writing 106/204/302 and 110/209/306 with set_clean_motor_mode is accepted and read back as 106/204/300 and 110/209/306. Fixes #977 Co-Authored-By: Claude Opus 5.5 --- roborock/data/v1/v1_clean_modes.py | 10 +++- tests/devices/traits/v1/test_status.py | 79 ++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/roborock/data/v1/v1_clean_modes.py b/roborock/data/v1/v1_clean_modes.py index 7d1e1fe0..87853875 100644 --- a/roborock/data/v1/v1_clean_modes.py +++ b/roborock/data/v1/v1_clean_modes.py @@ -243,9 +243,15 @@ def get_cleaning_mode_options(features: DeviceFeatures) -> list[CleaningMode]: options = [CleaningMode.VACUUM, CleaningMode.VAC_AND_MOP] if features.is_pure_clean_mop_supported: options.append(CleaningMode.MOP) - if features.is_customized_clean_supported and WaterModes.CUSTOMIZED in supported_water_modes: + # Water-slide devices report flow as slider positions, so their water mode + # list has no CUSTOMIZED (204) or SMART_MODE (209) entry, but they still + # accept and report those codes for the custom and SmartPlan modes. + is_water_slide = features.is_water_slide_mode_supported + if features.is_customized_clean_supported and (is_water_slide or WaterModes.CUSTOMIZED in supported_water_modes): options.append(CleaningMode.CUSTOM) - if features.is_smart_clean_mode_set_supported and WaterModes.SMART_MODE in supported_water_modes: + if features.is_smart_clean_mode_set_supported and ( + is_water_slide or WaterModes.SMART_MODE in supported_water_modes + ): options.append(CleaningMode.SMART_MODE) return options diff --git a/tests/devices/traits/v1/test_status.py b/tests/devices/traits/v1/test_status.py index b30b24ad..2cb1bd39 100644 --- a/tests/devices/traits/v1/test_status.py +++ b/tests/devices/traits/v1/test_status.py @@ -747,21 +747,92 @@ async def test_set_cleaning_mode_water_slide_wire_payload( mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.SET_CLEAN_MOTOR_MODE, params=[expected]) -def test_cleaning_mode_options_water_slide_device() -> None: - """Water-slide devices should not expose unsupported custom or smart water modes.""" +@pytest.mark.parametrize( + ("customized_supported", "smart_supported", "expected_extra"), + [ + (True, True, [CleaningMode.CUSTOM, CleaningMode.SMART_MODE]), + (True, False, [CleaningMode.CUSTOM]), + (False, True, [CleaningMode.SMART_MODE]), + (False, False, []), + ], +) +def test_cleaning_mode_options_water_slide_device( + customized_supported: bool, + smart_supported: bool, + expected_extra: list[CleaningMode], +) -> None: + """Water-slide devices offer custom and SmartPlan modes when their feature flags say so.""" status_trait = _create_cleaning_mode_status_trait( is_water_slide_mode_supported=True, - is_customized_clean_supported=True, - is_smart_clean_mode_set_supported=True, + is_customized_clean_supported=customized_supported, + is_smart_clean_mode_set_supported=smart_supported, ) assert status_trait.cleaning_mode_options == [ CleaningMode.VACUUM, CleaningMode.VAC_AND_MOP, CleaningMode.MOP, + *expected_extra, ] +@pytest.mark.parametrize( + ("fan_power", "water_box_mode", "mop_mode", "expected_mode"), + [ + # Readback from a Qrevo Edge 3 Pro (a296) after writing each mode. + (106, 204, 300, CleaningMode.CUSTOM), + (110, 209, 306, CleaningMode.SMART_MODE), + # Qrevo Edge 2 Flow (a328) in custom mode, from #977. + (106, 204, 302, CleaningMode.CUSTOM), + ], +) +def test_current_cleaning_mode_water_slide_special_modes( + fan_power: int, + water_box_mode: int, + mop_mode: int, + expected_mode: CleaningMode, +) -> None: + """The current custom/SmartPlan mode on a water-slide device is one of its options.""" + status_trait = _create_cleaning_mode_status_trait( + is_water_slide_mode_supported=True, + is_customized_clean_supported=True, + is_smart_clean_mode_set_supported=True, + ) + status_trait.fan_power = fan_power + status_trait.water_box_mode = water_box_mode + status_trait.mop_mode = mop_mode + + assert status_trait.current_cleaning_mode == expected_mode + assert status_trait.current_cleaning_mode in status_trait.cleaning_mode_options + + +@pytest.mark.parametrize( + ("cleaning_mode", "expected_params"), + [ + (CleaningMode.CUSTOM, {"fan_power": 106, "water_box_mode": 204, "mop_mode": 302}), + (CleaningMode.SMART_MODE, {"fan_power": 110, "water_box_mode": 209, "mop_mode": 306}), + ], +) +async def test_set_cleaning_mode_water_slide_special_modes( + mock_rpc_channel: AsyncMock, + cleaning_mode: CleaningMode, + expected_params: dict[str, int], +) -> None: + """Custom and SmartPlan use the same special codes on water-slide devices.""" + status_trait = _create_cleaning_mode_status_trait( + is_water_slide_mode_supported=True, + is_customized_clean_supported=True, + is_smart_clean_mode_set_supported=True, + ) + status_trait._rpc_channel = mock_rpc_channel # type: ignore[assignment] + + await status_trait.set_cleaning_mode(cleaning_mode) + + mock_rpc_channel.send_command.assert_called_once_with( + RoborockCommand.SET_CLEAN_MOTOR_MODE, params=[expected_params] + ) + + def test_current_cleaning_mode_gentle_not_mop_without_pure_mop() -> None: """Test code 105 is not treated as mop-only on devices without pure mop.""" status_trait = _create_cleaning_mode_status_trait(is_pure_clean_mop_supported=False)