From 0852ecb5d62872b1a35e59bdd7cb55c863e088ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 21 Mar 2026 20:52:44 +0100 Subject: [PATCH 01/32] feat(roborock): Add Q10 S5+ cleaning mode select entity Add select entity support for Q10 devices with: - RoborockQ10CleanModeSelectEntity for setting cleaning mode - Helper function _get_q10_cleaning_mode to extract current mode from Q10 data - Integration with Q10UpdateCoordinator for push-based updates - Support for YXCleanType enum modes (vacuum, vac_and_mop, mop, auto, etc.) - Async command sending with set_clean_mode via Q10 vacuum trait All existing select tests pass (18/18) for V1, Q7, and Zeo devices. --- homeassistant/components/roborock/select.py | 83 +++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 0ff27d8145f945..375f3bc15a4c37 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -20,6 +20,7 @@ ZeoSpin, ZeoTemperature, ) +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXCleanType from roborock.devices.traits.b01 import Q7PropertiesApi from roborock.devices.traits.v1 import PropertiesApi from roborock.devices.traits.v1.home import HomeTrait @@ -37,6 +38,7 @@ from .const import DOMAIN, MAP_SLEEP from .coordinator import ( RoborockB01Q7UpdateCoordinator, + RoborockB01Q10UpdateCoordinator, RoborockConfigEntry, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, @@ -44,6 +46,7 @@ from .entity import ( RoborockCoordinatedEntityA01, RoborockCoordinatedEntityB01Q7, + RoborockCoordinatedEntityB01Q10, RoborockCoordinatedEntityV1, ) @@ -266,6 +269,10 @@ async def async_setup_entry( for description in A01_SELECT_DESCRIPTIONS if description.data_protocol in coordinator.request_protocols ) + async_add_entities( + RoborockQ10CleanModeSelectEntity(coordinator) + for coordinator in config_entry.runtime_data.b01_q10 + ) class RoborockB01SelectEntity(RoborockCoordinatedEntityB01Q7, SelectEntity): @@ -466,3 +473,79 @@ def current_option(self) -> str | None: self.entity_description.key, ) return str(current_value) + + +def _get_q10_cleaning_mode(data: Any) -> str | None: + """Get cleaning mode from Q10 data.""" + if isinstance(data, dict): + # Q10 dict data - check for CLEAN_MODE key + if B01_Q10_DP.CLEAN_MODE in data: + clean_mode_value = data[B01_Q10_DP.CLEAN_MODE] + if isinstance(clean_mode_value, int): + try: + mode_enum = YXCleanType.from_code(clean_mode_value) + return mode_enum.name.lower() + except ValueError, AttributeError: + return None + if isinstance(clean_mode_value, YXCleanType): + return clean_mode_value.name.lower() + return None + # B01Props-like object + if hasattr(data, "mode") and data.mode: + return data.mode.value + return None + + +class RoborockQ10CleanModeSelectEntity(RoborockCoordinatedEntityB01Q10, SelectEntity): + """Select entity for Q10 cleaning mode.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "cleaning_mode" + coordinator: RoborockB01Q10UpdateCoordinator + + def __init__( + self, + coordinator: RoborockB01Q10UpdateCoordinator, + ) -> None: + """Create a select entity for Q10 cleaning mode.""" + super().__init__( + f"cleaning_mode_{coordinator.duid_slug}", + coordinator, + ) + + @property + def options(self) -> list[str]: + """Return available cleaning modes.""" + return [ + option.name.lower() + for option in YXCleanType + if option != YXCleanType.UNKNOWN + ] + + @property + def current_option(self) -> str | None: + """Get the current cleaning mode.""" + return _get_q10_cleaning_mode(self.coordinator.data) + + async def async_select_option(self, option: str) -> None: + """Set the cleaning mode.""" + try: + mode = None + for clean_mode in YXCleanType: + if clean_mode.name.lower() == option: + mode = clean_mode + break + if mode is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={"command": "set_clean_mode"}, + ) + await self.coordinator.api.vacuum.set_clean_mode(mode) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={"command": "set_clean_mode"}, + ) from err + await self.coordinator.async_refresh() From 5a4f43525c3ecb54fabdaf8c835364003c9b0f0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 21 Mar 2026 20:55:35 +0100 Subject: [PATCH 02/32] test(roborock): Add Q10 cleaning mode select entity tests Add test coverage for the new RoborockQ10CleanModeSelectEntity: - test_q10_cleaning_mode_select_current_option: Verify entity exists with valid options - test_q10_cleaning_mode_select_update_success: Test setting a valid cleaning mode - test_q10_cleaning_mode_select_update_failure: Test error handling when API call fails All select tests pass (21/18 - 3 new Q10 tests added). --- tests/components/roborock/test_select.py | 66 ++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 31e77ee29c8aff..58219c52b8926c 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -10,12 +10,14 @@ WaterLevelMapping, ZeoProgram, ) +from roborock.data.b01_q10.b01_q10_code_mappings import YXCleanType from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockZeoProtocol from homeassistant.components.roborock import DOMAIN from homeassistant.components.roborock.select import ( A01_SELECT_DESCRIPTIONS, + RoborockQ10CleanModeSelectEntity, RoborockSelectEntityA01, ) from homeassistant.const import SERVICE_SELECT_OPTION, STATE_UNKNOWN, Platform @@ -383,3 +385,67 @@ async def test_update_failure_zeo_invalid_option() -> None: await entity.async_select_option("invalid_option") coordinator.api.set_value.assert_not_called() + +@pytest.fixture +def q10_device(fake_devices: list[FakeDevice]) -> FakeDevice: + """Get the fake Q10 vacuum device.""" + # The Q10 is the fifth device in the list (index 4) based on HOME_DATA + return fake_devices[4] + + +async def test_q10_cleaning_mode_select_current_option( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q10_device: FakeDevice, +) -> None: + """Test Q10 cleaning mode select entity current option.""" + entity_id = "select.roborock_q10_s5_cleaning_mode" + state = hass.states.get(entity_id) + assert state is not None + # Verify the entity exists and has valid options + assert state.attributes.get("options") is not None + + +async def test_q10_cleaning_mode_select_update_success( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q10_device: FakeDevice, +) -> None: + """Test setting Q10 cleaning mode select option.""" + entity_id = "select.roborock_q10_s5_cleaning_mode" + assert hass.states.get(entity_id) is not None + + # Test setting value + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "both_work"}, + blocking=True, + target={"entity_id": entity_id}, + ) + + assert q10_device.b01_q10_properties + assert q10_device.b01_q10_properties.vacuum.set_clean_mode.call_count == 1 + + +async def test_q10_cleaning_mode_select_update_failure( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q10_device: FakeDevice, +) -> None: + """Test failure when setting Q10 cleaning mode.""" + assert q10_device.b01_q10_properties + q10_device.b01_q10_properties.vacuum.set_clean_mode.side_effect = ( + RoborockException + ) + entity_id = "select.roborock_q10_s5_cleaning_mode" + assert hass.states.get(entity_id) is not None + + with pytest.raises(HomeAssistantError, match="set_clean_mode"): + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "both_work"}, + blocking=True, + target={"entity_id": entity_id}, + ) \ No newline at end of file From 213b328a7e8114d0ec1da0105c17187610631220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 21 Mar 2026 20:57:27 +0100 Subject: [PATCH 03/32] i18n(roborock): Add Q10 cleaning mode translations Add translation keys for Q10 specific cleaning modes: - both_work: Vacuum and mop - only_sweep: Vacuum only - only_mop: Mop only Keep existing Q7 mode translations for backward compatibility. --- homeassistant/components/roborock/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 64f09e5dcb65fe..fd9ade0736c946 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -115,7 +115,10 @@ "cleaning_mode": { "name": "Cleaning mode", "state": { + "both_work": "Vacuum and mop", "mop": "Mop only", + "only_mop": "Mop only", + "only_sweep": "Vacuum only", "vac_and_mop": "Vacuum and mop", "vacuum": "Vacuum only" } From 15f58cab3aea25f865df65cf97614f18a53a29f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 21 Mar 2026 21:00:49 +0100 Subject: [PATCH 04/32] refactor(roborock): Map Q10 cleaning modes to existing Q7 state keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _map_q10_clean_mode_to_state_key() function to map YXCleanType enum values to existing Q7 keys - BOTH_WORK → vac_and_mop - ONLY_SWEEP → vacuum - ONLY_MOP → mop - Remove duplicate translations from strings.json (both_work, only_sweep, only_mop) - Update Q10 entity to use mapped state keys instead of enum names - Update tests to use correct mapped option names - All 21 select tests passing (18 existing + 3 Q10) --- homeassistant/components/roborock/select.py | 18 ++++++++++++++---- homeassistant/components/roborock/strings.json | 3 --- tests/components/roborock/test_select.py | 4 ++-- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 375f3bc15a4c37..efd10b042591f1 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -475,6 +475,16 @@ def current_option(self) -> str | None: return str(current_value) +def _map_q10_clean_mode_to_state_key(mode: YXCleanType) -> str: + """Map Q10 clean mode to HA state key (matching Q7 keys).""" + mapping = { + YXCleanType.BOTH_WORK: "vac_and_mop", + YXCleanType.ONLY_SWEEP: "vacuum", + YXCleanType.ONLY_MOP: "mop", + } + return mapping.get(mode, "unknown") + + def _get_q10_cleaning_mode(data: Any) -> str | None: """Get cleaning mode from Q10 data.""" if isinstance(data, dict): @@ -484,11 +494,11 @@ def _get_q10_cleaning_mode(data: Any) -> str | None: if isinstance(clean_mode_value, int): try: mode_enum = YXCleanType.from_code(clean_mode_value) - return mode_enum.name.lower() + return _map_q10_clean_mode_to_state_key(mode_enum) except ValueError, AttributeError: return None if isinstance(clean_mode_value, YXCleanType): - return clean_mode_value.name.lower() + return _map_q10_clean_mode_to_state_key(clean_mode_value) return None # B01Props-like object if hasattr(data, "mode") and data.mode: @@ -517,7 +527,7 @@ def __init__( def options(self) -> list[str]: """Return available cleaning modes.""" return [ - option.name.lower() + _map_q10_clean_mode_to_state_key(option) for option in YXCleanType if option != YXCleanType.UNKNOWN ] @@ -532,7 +542,7 @@ async def async_select_option(self, option: str) -> None: try: mode = None for clean_mode in YXCleanType: - if clean_mode.name.lower() == option: + if _map_q10_clean_mode_to_state_key(clean_mode) == option: mode = clean_mode break if mode is None: diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index fd9ade0736c946..64f09e5dcb65fe 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -115,10 +115,7 @@ "cleaning_mode": { "name": "Cleaning mode", "state": { - "both_work": "Vacuum and mop", "mop": "Mop only", - "only_mop": "Mop only", - "only_sweep": "Vacuum only", "vac_and_mop": "Vacuum and mop", "vacuum": "Vacuum only" } diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 58219c52b8926c..cb7138a29039a2 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -419,7 +419,7 @@ async def test_q10_cleaning_mode_select_update_success( await hass.services.async_call( "select", SERVICE_SELECT_OPTION, - service_data={"option": "both_work"}, + service_data={"option": "vac_and_mop"}, blocking=True, target={"entity_id": entity_id}, ) @@ -445,7 +445,7 @@ async def test_q10_cleaning_mode_select_update_failure( await hass.services.async_call( "select", SERVICE_SELECT_OPTION, - service_data={"option": "both_work"}, + service_data={"option": "vac_and_mop"}, blocking=True, target={"entity_id": entity_id}, ) \ No newline at end of file From d5af70eb15fda6f619902f9829a1eae9a0ec00bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 21 Mar 2026 21:08:26 +0100 Subject: [PATCH 05/32] fix(roborock): Use Q10 status trait for cleaning mode select state --- homeassistant/components/roborock/select.py | 50 +++++++++------------ 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index efd10b042591f1..7cfbaf6ec6f880 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -475,35 +475,14 @@ def current_option(self) -> str | None: return str(current_value) -def _map_q10_clean_mode_to_state_key(mode: YXCleanType) -> str: - """Map Q10 clean mode to HA state key (matching Q7 keys).""" +def _map_q10_clean_mode_to_state_key(mode_code: int) -> str | None: + """Map Q10 clean mode code to HA state key (matching Q7 keys).""" mapping = { - YXCleanType.BOTH_WORK: "vac_and_mop", - YXCleanType.ONLY_SWEEP: "vacuum", - YXCleanType.ONLY_MOP: "mop", + 1: "vac_and_mop", + 2: "vacuum", + 3: "mop", } - return mapping.get(mode, "unknown") - - -def _get_q10_cleaning_mode(data: Any) -> str | None: - """Get cleaning mode from Q10 data.""" - if isinstance(data, dict): - # Q10 dict data - check for CLEAN_MODE key - if B01_Q10_DP.CLEAN_MODE in data: - clean_mode_value = data[B01_Q10_DP.CLEAN_MODE] - if isinstance(clean_mode_value, int): - try: - mode_enum = YXCleanType.from_code(clean_mode_value) - return _map_q10_clean_mode_to_state_key(mode_enum) - except ValueError, AttributeError: - return None - if isinstance(clean_mode_value, YXCleanType): - return _map_q10_clean_mode_to_state_key(clean_mode_value) - return None - # B01Props-like object - if hasattr(data, "mode") and data.mode: - return data.mode.value - return None + return mapping.get(mode_code) class RoborockQ10CleanModeSelectEntity(RoborockCoordinatedEntityB01Q10, SelectEntity): @@ -523,26 +502,37 @@ def __init__( coordinator, ) + async def async_added_to_hass(self) -> None: + """Register trait listener for push-based status updates.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.api.status.add_update_listener(self.async_write_ha_state) + ) + @property def options(self) -> list[str]: """Return available cleaning modes.""" return [ - _map_q10_clean_mode_to_state_key(option) + state_key for option in YXCleanType + if (state_key := _map_q10_clean_mode_to_state_key(option.code)) is not None if option != YXCleanType.UNKNOWN ] @property def current_option(self) -> str | None: """Get the current cleaning mode.""" - return _get_q10_cleaning_mode(self.coordinator.data) + clean_mode = self.coordinator.api.status.clean_mode + if clean_mode is None: + return None + return _map_q10_clean_mode_to_state_key(clean_mode.code) async def async_select_option(self, option: str) -> None: """Set the cleaning mode.""" try: mode = None for clean_mode in YXCleanType: - if _map_q10_clean_mode_to_state_key(clean_mode) == option: + if _map_q10_clean_mode_to_state_key(clean_mode.code) == option: mode = clean_mode break if mode is None: From dd439b6a2a3d38a1a482ce8725e9b3404f45bf9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 21 Mar 2026 21:10:39 +0100 Subject: [PATCH 06/32] fix(roborock): Update Q10 cleaning mode select test to include B01_Q10_DP and validate state options --- tests/components/roborock/test_select.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index cb7138a29039a2..95635942470c13 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -10,14 +10,13 @@ WaterLevelMapping, ZeoProgram, ) -from roborock.data.b01_q10.b01_q10_code_mappings import YXCleanType +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockZeoProtocol from homeassistant.components.roborock import DOMAIN from homeassistant.components.roborock.select import ( A01_SELECT_DESCRIPTIONS, - RoborockQ10CleanModeSelectEntity, RoborockSelectEntityA01, ) from homeassistant.const import SERVICE_SELECT_OPTION, STATE_UNKNOWN, Platform @@ -386,6 +385,7 @@ async def test_update_failure_zeo_invalid_option() -> None: coordinator.api.set_value.assert_not_called() + @pytest.fixture def q10_device(fake_devices: list[FakeDevice]) -> FakeDevice: """Get the fake Q10 vacuum device.""" @@ -402,8 +402,17 @@ async def test_q10_cleaning_mode_select_current_option( entity_id = "select.roborock_q10_s5_cleaning_mode" state = hass.states.get(entity_id) assert state is not None - # Verify the entity exists and has valid options - assert state.attributes.get("options") is not None + options = state.attributes.get("options") + assert options is not None + + assert q10_device.b01_q10_properties + q10_device.b01_q10_properties.status.update_from_dps({B01_Q10_DP.CLEAN_MODE: 1}) + await hass.async_block_till_done() + + updated_state = hass.states.get(entity_id) + assert updated_state is not None + assert updated_state.state is not STATE_UNKNOWN + assert updated_state.state in options async def test_q10_cleaning_mode_select_update_success( @@ -435,9 +444,7 @@ async def test_q10_cleaning_mode_select_update_failure( ) -> None: """Test failure when setting Q10 cleaning mode.""" assert q10_device.b01_q10_properties - q10_device.b01_q10_properties.vacuum.set_clean_mode.side_effect = ( - RoborockException - ) + q10_device.b01_q10_properties.vacuum.set_clean_mode.side_effect = RoborockException entity_id = "select.roborock_q10_s5_cleaning_mode" assert hass.states.get(entity_id) is not None @@ -448,4 +455,4 @@ async def test_q10_cleaning_mode_select_update_failure( service_data={"option": "vac_and_mop"}, blocking=True, target={"entity_id": entity_id}, - ) \ No newline at end of file + ) From d059c53188d69e5508a8a30d4aeffc8963d3092f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 21 Mar 2026 21:11:20 +0100 Subject: [PATCH 07/32] fix(roborock): Import YXCleanType for Q10 cleaning mode select updates --- tests/components/roborock/test_select.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 95635942470c13..c7e94033355a2b 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -10,7 +10,7 @@ WaterLevelMapping, ZeoProgram, ) -from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXCleanType from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockZeoProtocol @@ -435,6 +435,9 @@ async def test_q10_cleaning_mode_select_update_success( assert q10_device.b01_q10_properties assert q10_device.b01_q10_properties.vacuum.set_clean_mode.call_count == 1 + q10_device.b01_q10_properties.vacuum.set_clean_mode.assert_called_once_with( + YXCleanType.BOTH_WORK + ) async def test_q10_cleaning_mode_select_update_failure( From 8452734a352b37a3b46f3aa3961452e2e8b6b370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 21 Mar 2026 22:18:22 +0100 Subject: [PATCH 08/32] fix(roborock): Remove unused import of B01_Q10_DP from select.py --- homeassistant/components/roborock/select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 7cfbaf6ec6f880..5be23f014ac383 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -20,7 +20,7 @@ ZeoSpin, ZeoTemperature, ) -from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXCleanType +from roborock.data.b01_q10.b01_q10_code_mappings import YXCleanType from roborock.devices.traits.b01 import Q7PropertiesApi from roborock.devices.traits.v1 import PropertiesApi from roborock.devices.traits.v1.home import HomeTrait From 9e5435b554213b0b68aff454c90699bb18564155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 21 Mar 2026 22:19:38 +0100 Subject: [PATCH 09/32] fix(roborock): Update Q10 cleaning mode select test to assert specific state --- tests/components/roborock/test_select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index c7e94033355a2b..8da2a4f87fb2a1 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -412,7 +412,7 @@ async def test_q10_cleaning_mode_select_current_option( updated_state = hass.states.get(entity_id) assert updated_state is not None assert updated_state.state is not STATE_UNKNOWN - assert updated_state.state in options + assert updated_state.state == "vac_and_mop" async def test_q10_cleaning_mode_select_update_success( From b5fc23b4b0de1e8cc0716191a47e4e8b7ea87ff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 21 Mar 2026 22:21:08 +0100 Subject: [PATCH 10/32] refactor(roborock): Rename q10_device fixture to fake_q10_vacuum and update references --- tests/components/roborock/test_select.py | 31 +++++++++++------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 8da2a4f87fb2a1..181490a4e68c4c 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -386,17 +386,10 @@ async def test_update_failure_zeo_invalid_option() -> None: coordinator.api.set_value.assert_not_called() -@pytest.fixture -def q10_device(fake_devices: list[FakeDevice]) -> FakeDevice: - """Get the fake Q10 vacuum device.""" - # The Q10 is the fifth device in the list (index 4) based on HOME_DATA - return fake_devices[4] - - async def test_q10_cleaning_mode_select_current_option( hass: HomeAssistant, setup_entry: MockConfigEntry, - q10_device: FakeDevice, + fake_q10_vacuum: FakeDevice, ) -> None: """Test Q10 cleaning mode select entity current option.""" entity_id = "select.roborock_q10_s5_cleaning_mode" @@ -405,8 +398,10 @@ async def test_q10_cleaning_mode_select_current_option( options = state.attributes.get("options") assert options is not None - assert q10_device.b01_q10_properties - q10_device.b01_q10_properties.status.update_from_dps({B01_Q10_DP.CLEAN_MODE: 1}) + assert fake_q10_vacuum.b01_q10_properties + fake_q10_vacuum.b01_q10_properties.status.update_from_dps( + {B01_Q10_DP.CLEAN_MODE: 1} + ) await hass.async_block_till_done() updated_state = hass.states.get(entity_id) @@ -418,7 +413,7 @@ async def test_q10_cleaning_mode_select_current_option( async def test_q10_cleaning_mode_select_update_success( hass: HomeAssistant, setup_entry: MockConfigEntry, - q10_device: FakeDevice, + fake_q10_vacuum: FakeDevice, ) -> None: """Test setting Q10 cleaning mode select option.""" entity_id = "select.roborock_q10_s5_cleaning_mode" @@ -433,9 +428,9 @@ async def test_q10_cleaning_mode_select_update_success( target={"entity_id": entity_id}, ) - assert q10_device.b01_q10_properties - assert q10_device.b01_q10_properties.vacuum.set_clean_mode.call_count == 1 - q10_device.b01_q10_properties.vacuum.set_clean_mode.assert_called_once_with( + assert fake_q10_vacuum.b01_q10_properties + assert fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.call_count == 1 + fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.assert_called_once_with( YXCleanType.BOTH_WORK ) @@ -443,11 +438,13 @@ async def test_q10_cleaning_mode_select_update_success( async def test_q10_cleaning_mode_select_update_failure( hass: HomeAssistant, setup_entry: MockConfigEntry, - q10_device: FakeDevice, + fake_q10_vacuum: FakeDevice, ) -> None: """Test failure when setting Q10 cleaning mode.""" - assert q10_device.b01_q10_properties - q10_device.b01_q10_properties.vacuum.set_clean_mode.side_effect = RoborockException + assert fake_q10_vacuum.b01_q10_properties + fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.side_effect = ( + RoborockException + ) entity_id = "select.roborock_q10_s5_cleaning_mode" assert hass.states.get(entity_id) is not None From e4b86e78d8ada93f438b9879e109ba49c8d25a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 21 Mar 2026 22:22:05 +0100 Subject: [PATCH 11/32] fix(roborock): Update async_select_option to raise ServiceValidationError for invalid options --- homeassistant/components/roborock/select.py | 24 +++++++++++---------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 5be23f014ac383..873f4944b17a45 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -529,18 +529,20 @@ def current_option(self) -> str | None: async def async_select_option(self, option: str) -> None: """Set the cleaning mode.""" + # Find the mode that matches the selected option + mode = None + for clean_mode in YXCleanType: + if _map_q10_clean_mode_to_state_key(clean_mode.code) == option: + mode = clean_mode + break + + if mode is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="select_option_failed", + ) + try: - mode = None - for clean_mode in YXCleanType: - if _map_q10_clean_mode_to_state_key(clean_mode.code) == option: - mode = clean_mode - break - if mode is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="command_failed", - translation_placeholders={"command": "set_clean_mode"}, - ) await self.coordinator.api.vacuum.set_clean_mode(mode) except RoborockException as err: raise HomeAssistantError( From c23443e1a02eb6b438a3705674b0e6d842c8f48f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 22 Mar 2026 15:49:31 +0100 Subject: [PATCH 12/32] Refactor Q10 clean mode mapping to use a constant dictionary --- homeassistant/components/roborock/select.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 873f4944b17a45..4b991138bacf31 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -477,12 +477,15 @@ def current_option(self) -> str | None: def _map_q10_clean_mode_to_state_key(mode_code: int) -> str | None: """Map Q10 clean mode code to HA state key (matching Q7 keys).""" - mapping = { - 1: "vac_and_mop", - 2: "vacuum", - 3: "mop", - } - return mapping.get(mode_code) + match mode_code: + case 1: + return "vac_and_mop" + case 2: + return "vacuum" + case 3: + return "mop" + case _: + return None class RoborockQ10CleanModeSelectEntity(RoborockCoordinatedEntityB01Q10, SelectEntity): From 9ef32f3f37c392ce749d545cee6736aebc2813ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 22 Mar 2026 15:50:18 +0100 Subject: [PATCH 13/32] Fix assertion in Q10 cleaning mode test to check for non-unknown state --- tests/components/roborock/test_select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 181490a4e68c4c..5f5abc7c53b3f9 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -406,7 +406,7 @@ async def test_q10_cleaning_mode_select_current_option( updated_state = hass.states.get(entity_id) assert updated_state is not None - assert updated_state.state is not STATE_UNKNOWN + assert updated_state.state != STATE_UNKNOWN assert updated_state.state == "vac_and_mop" From 2530186b6369ee5c36b39552908935bf5008d969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 22 Mar 2026 15:52:45 +0100 Subject: [PATCH 14/32] Refactor Q10 clean mode mapping to use dictionaries for improved clarity and support legacy values --- homeassistant/components/roborock/select.py | 56 +++++++++++++-------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 4b991138bacf31..44a10462a05d61 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -54,6 +54,16 @@ _LOGGER = logging.getLogger(__name__) +Q10_CLEAN_MODE_LEGACY_TO_STATE_KEY: dict[str, str] = { + "bothwork": "vac_and_mop", + "onlysweep": "vacuum", + "onlymop": "mop", +} + +Q10_CLEAN_MODE_STATE_KEY_TO_LEGACY: dict[str, str] = { + value: key for key, value in Q10_CLEAN_MODE_LEGACY_TO_STATE_KEY.items() +} + @dataclass(frozen=True, kw_only=True) class RoborockSelectDescription(SelectEntityDescription): @@ -475,17 +485,26 @@ def current_option(self) -> str | None: return str(current_value) -def _map_q10_clean_mode_to_state_key(mode_code: int) -> str | None: - """Map Q10 clean mode code to HA state key (matching Q7 keys).""" - match mode_code: - case 1: - return "vac_and_mop" - case 2: - return "vacuum" - case 3: - return "mop" - case _: - return None +def _map_q10_clean_mode_to_state_key(clean_mode: YXCleanType) -> str | None: + """Map Q10 clean mode enum value to HA state key (matching Q7 keys).""" + if clean_mode == YXCleanType.UNKNOWN: + return None + + if clean_mode.value in {"vac_and_mop", "vacuum", "mop"}: + return clean_mode.value + + return Q10_CLEAN_MODE_LEGACY_TO_STATE_KEY.get(clean_mode.value) + + +def _map_q10_state_key_to_clean_mode(state_key: str) -> YXCleanType | None: + """Map HA state key back to Q10 clean mode enum, supporting legacy values.""" + if (clean_mode := YXCleanType.from_any_optional(state_key)) is not None: + return clean_mode + + if legacy_value := Q10_CLEAN_MODE_STATE_KEY_TO_LEGACY.get(state_key): + return YXCleanType.from_any_optional(legacy_value) + + return None class RoborockQ10CleanModeSelectEntity(RoborockCoordinatedEntityB01Q10, SelectEntity): @@ -518,7 +537,7 @@ def options(self) -> list[str]: return [ state_key for option in YXCleanType - if (state_key := _map_q10_clean_mode_to_state_key(option.code)) is not None + if (state_key := _map_q10_clean_mode_to_state_key(option)) is not None if option != YXCleanType.UNKNOWN ] @@ -528,18 +547,13 @@ def current_option(self) -> str | None: clean_mode = self.coordinator.api.status.clean_mode if clean_mode is None: return None - return _map_q10_clean_mode_to_state_key(clean_mode.code) + if (clean_mode_enum := YXCleanType.from_code_optional(clean_mode.code)) is None: + return None + return _map_q10_clean_mode_to_state_key(clean_mode_enum) async def async_select_option(self, option: str) -> None: """Set the cleaning mode.""" - # Find the mode that matches the selected option - mode = None - for clean_mode in YXCleanType: - if _map_q10_clean_mode_to_state_key(clean_mode.code) == option: - mode = clean_mode - break - - if mode is None: + if (mode := _map_q10_state_key_to_clean_mode(option)) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="select_option_failed", From 85653952ac4c59a4bc5bd191a9524ae97902cdad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 22 Mar 2026 16:05:33 +0100 Subject: [PATCH 15/32] Update Q10 cleaning mode test to use YXCleanType for improved clarity --- tests/components/roborock/test_select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 5f5abc7c53b3f9..b2c4cc744267cf 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -400,7 +400,7 @@ async def test_q10_cleaning_mode_select_current_option( assert fake_q10_vacuum.b01_q10_properties fake_q10_vacuum.b01_q10_properties.status.update_from_dps( - {B01_Q10_DP.CLEAN_MODE: 1} + {B01_Q10_DP.CLEAN_MODE: YXCleanType.BOTH_WORK.code} ) await hass.async_block_till_done() From c29fd2c801ad737a8f2639872600c8d4ee0b7802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 22 Mar 2026 16:09:26 +0100 Subject: [PATCH 16/32] Add test for handling invalid cleaning mode selection in Q10 --- tests/components/roborock/test_select.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index b2c4cc744267cf..5922df55b73738 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -17,6 +17,7 @@ from homeassistant.components.roborock import DOMAIN from homeassistant.components.roborock.select import ( A01_SELECT_DESCRIPTIONS, + RoborockQ10CleanModeSelectEntity, RoborockSelectEntityA01, ) from homeassistant.const import SERVICE_SELECT_OPTION, STATE_UNKNOWN, Platform @@ -456,3 +457,22 @@ async def test_q10_cleaning_mode_select_update_failure( blocking=True, target={"entity_id": entity_id}, ) + + +async def test_q10_cleaning_mode_select_invalid_option( + fake_q10_vacuum: FakeDevice, +) -> None: + """Test that an invalid option raises ServiceValidationError and does not call set_clean_mode.""" + assert fake_q10_vacuum.b01_q10_properties + coordinator = Mock( + duid_slug="q10_duid", + device_info=Mock(), + api=fake_q10_vacuum.b01_q10_properties, + async_refresh=AsyncMock(), + ) + entity = RoborockQ10CleanModeSelectEntity(coordinator) + + with pytest.raises(ServiceValidationError): + await entity.async_select_option("invalid_option") + + fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.assert_not_called() From 0261b6c734b3b8b0b4d48e0b3f54beeab5fe8308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 22 Mar 2026 16:11:09 +0100 Subject: [PATCH 17/32] Remove unnecessary async refresh call in Q10 clean mode selection --- homeassistant/components/roborock/select.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 44a10462a05d61..48a0ef3f18d043 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -567,4 +567,3 @@ async def async_select_option(self, option: str) -> None: translation_key="command_failed", translation_placeholders={"command": "set_clean_mode"}, ) from err - await self.coordinator.async_refresh() From 9057aa93a07842b81a8510538fb11e5087ecea27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 22 Mar 2026 16:12:06 +0100 Subject: [PATCH 18/32] Add assertions to validate Q10 cleaning mode options in test --- tests/components/roborock/test_select.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 5922df55b73738..7ba49a96805e66 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -398,6 +398,8 @@ async def test_q10_cleaning_mode_select_current_option( assert state is not None options = state.attributes.get("options") assert options is not None + assert set(options) == {"vac_and_mop", "vacuum", "mop"} + assert len(options) == len(set(options)) assert fake_q10_vacuum.b01_q10_properties fake_q10_vacuum.b01_q10_properties.status.update_from_dps( From bd1c87d311ad339cc6caad44d86693c693d0c011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 22 Mar 2026 22:46:27 +0100 Subject: [PATCH 19/32] Update cleaning mode selection for Q10 vacuum to use VAC_AND_MOP --- tests/components/roborock/test_select.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 7ba49a96805e66..7a98a5f454bd44 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -403,7 +403,7 @@ async def test_q10_cleaning_mode_select_current_option( assert fake_q10_vacuum.b01_q10_properties fake_q10_vacuum.b01_q10_properties.status.update_from_dps( - {B01_Q10_DP.CLEAN_MODE: YXCleanType.BOTH_WORK.code} + {B01_Q10_DP.CLEAN_MODE: YXCleanType.VAC_AND_MOP.code} ) await hass.async_block_till_done() @@ -434,7 +434,7 @@ async def test_q10_cleaning_mode_select_update_success( assert fake_q10_vacuum.b01_q10_properties assert fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.call_count == 1 fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.assert_called_once_with( - YXCleanType.BOTH_WORK + YXCleanType.VAC_AND_MOP ) From 77b9243ce50fbfa9bd3e1eb7d8c1518756f152ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 22 Mar 2026 23:46:06 +0100 Subject: [PATCH 20/32] Remove UNKNOWN option from Q10 clean mode selection --- homeassistant/components/roborock/select.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 48a0ef3f18d043..580de6b049a008 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -538,7 +538,6 @@ def options(self) -> list[str]: state_key for option in YXCleanType if (state_key := _map_q10_clean_mode_to_state_key(option)) is not None - if option != YXCleanType.UNKNOWN ] @property From babc3ae1251dcc18fba92466f843b465eb530bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 23 Mar 2026 13:07:07 +0100 Subject: [PATCH 21/32] Refactor Q10 cleaning mode handling to remove legacy mappings and simplify state key mapping --- homeassistant/components/roborock/select.py | 62 ++++++--------------- 1 file changed, 18 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 580de6b049a008..e7770fc61b24e1 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -54,16 +54,6 @@ _LOGGER = logging.getLogger(__name__) -Q10_CLEAN_MODE_LEGACY_TO_STATE_KEY: dict[str, str] = { - "bothwork": "vac_and_mop", - "onlysweep": "vacuum", - "onlymop": "mop", -} - -Q10_CLEAN_MODE_STATE_KEY_TO_LEGACY: dict[str, str] = { - value: key for key, value in Q10_CLEAN_MODE_LEGACY_TO_STATE_KEY.items() -} - @dataclass(frozen=True, kw_only=True) class RoborockSelectDescription(SelectEntityDescription): @@ -485,28 +475,6 @@ def current_option(self) -> str | None: return str(current_value) -def _map_q10_clean_mode_to_state_key(clean_mode: YXCleanType) -> str | None: - """Map Q10 clean mode enum value to HA state key (matching Q7 keys).""" - if clean_mode == YXCleanType.UNKNOWN: - return None - - if clean_mode.value in {"vac_and_mop", "vacuum", "mop"}: - return clean_mode.value - - return Q10_CLEAN_MODE_LEGACY_TO_STATE_KEY.get(clean_mode.value) - - -def _map_q10_state_key_to_clean_mode(state_key: str) -> YXCleanType | None: - """Map HA state key back to Q10 clean mode enum, supporting legacy values.""" - if (clean_mode := YXCleanType.from_any_optional(state_key)) is not None: - return clean_mode - - if legacy_value := Q10_CLEAN_MODE_STATE_KEY_TO_LEGACY.get(state_key): - return YXCleanType.from_any_optional(legacy_value) - - return None - - class RoborockQ10CleanModeSelectEntity(RoborockCoordinatedEntityB01Q10, SelectEntity): """Select entity for Q10 cleaning mode.""" @@ -534,30 +502,36 @@ async def async_added_to_hass(self) -> None: @property def options(self) -> list[str]: """Return available cleaning modes.""" - return [ - state_key - for option in YXCleanType - if (state_key := _map_q10_clean_mode_to_state_key(option)) is not None - ] + # Use all YXCleanType values except UNKNOWN + return [mode.value for mode in YXCleanType if mode != YXCleanType.UNKNOWN] @property def current_option(self) -> str | None: """Get the current cleaning mode.""" clean_mode = self.coordinator.api.status.clean_mode - if clean_mode is None: - return None - if (clean_mode_enum := YXCleanType.from_code_optional(clean_mode.code)) is None: + if clean_mode is None or clean_mode.code is None: return None - return _map_q10_clean_mode_to_state_key(clean_mode_enum) + code = clean_mode.code + for mode in YXCleanType: + if getattr(mode, "code", None) == code: + if mode == YXCleanType.UNKNOWN: + return None + return mode.value + return None async def async_select_option(self, option: str) -> None: """Set the cleaning mode.""" - if (mode := _map_q10_state_key_to_clean_mode(option)) is None: + # Find enum by value (string), not by constructor + mode = None + for m in YXCleanType: + if m.value == option: + mode = m + break + if mode is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="select_option_failed", - ) - + ) from None try: await self.coordinator.api.vacuum.set_clean_mode(mode) except RoborockException as err: From f7944715ea8d287de75570e5fce5690eab85557c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <132135057+lboue@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:47:26 +0200 Subject: [PATCH 22/32] Refactor Q10 invalid option test to use hass.services.async_call Replace direct entity instantiation with hass.services.async_call to follow HA testing conventions. --- tests/components/roborock/test_select.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 7a98a5f454bd44..c861b10535650e 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -17,7 +17,6 @@ from homeassistant.components.roborock import DOMAIN from homeassistant.components.roborock.select import ( A01_SELECT_DESCRIPTIONS, - RoborockQ10CleanModeSelectEntity, RoborockSelectEntityA01, ) from homeassistant.const import SERVICE_SELECT_OPTION, STATE_UNKNOWN, Platform @@ -462,19 +461,22 @@ async def test_q10_cleaning_mode_select_update_failure( async def test_q10_cleaning_mode_select_invalid_option( + hass: HomeAssistant, + setup_entry: MockConfigEntry, fake_q10_vacuum: FakeDevice, ) -> None: """Test that an invalid option raises ServiceValidationError and does not call set_clean_mode.""" - assert fake_q10_vacuum.b01_q10_properties - coordinator = Mock( - duid_slug="q10_duid", - device_info=Mock(), - api=fake_q10_vacuum.b01_q10_properties, - async_refresh=AsyncMock(), - ) - entity = RoborockQ10CleanModeSelectEntity(coordinator) + entity_id = "select.roborock_q10_s5_cleaning_mode" + assert hass.states.get(entity_id) is not None with pytest.raises(ServiceValidationError): - await entity.async_select_option("invalid_option") + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "invalid_option"}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert fake_q10_vacuum.b01_q10_properties fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.assert_not_called() From 54f109c9d2692a96e26d68a34b2c27e3ac3e9afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <132135057+lboue@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:49:03 +0200 Subject: [PATCH 23/32] Assert initial state before update in Q10 cleaning mode test Add assertion on the initial STATE_UNKNOWN value so the subsequent state change is meaningful. --- tests/components/roborock/test_select.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index c861b10535650e..f4438219876ecd 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -395,6 +395,7 @@ async def test_q10_cleaning_mode_select_current_option( entity_id = "select.roborock_q10_s5_cleaning_mode" state = hass.states.get(entity_id) assert state is not None + assert state.state == STATE_UNKNOWN options = state.attributes.get("options") assert options is not None assert set(options) == {"vac_and_mop", "vacuum", "mop"} From c6117e60ac3e9c0eb883e739043de8a7b8f1406e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <132135057+lboue@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:49:44 +0200 Subject: [PATCH 24/32] Remove redundant STATE_UNKNOWN assertion before final state check --- tests/components/roborock/test_select.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index f4438219876ecd..eee91238dee3f5 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -409,7 +409,6 @@ async def test_q10_cleaning_mode_select_current_option( updated_state = hass.states.get(entity_id) assert updated_state is not None - assert updated_state.state != STATE_UNKNOWN assert updated_state.state == "vac_and_mop" From 04c647b1b1cd47c89c19d4be91812ccc55e54e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <132135057+lboue@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:50:48 +0200 Subject: [PATCH 25/32] Remove redundant duplicate check in Q10 options assertion --- tests/components/roborock/test_select.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index eee91238dee3f5..9c9e1d5e05e9da 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -399,7 +399,6 @@ async def test_q10_cleaning_mode_select_current_option( options = state.attributes.get("options") assert options is not None assert set(options) == {"vac_and_mop", "vacuum", "mop"} - assert len(options) == len(set(options)) assert fake_q10_vacuum.b01_q10_properties fake_q10_vacuum.b01_q10_properties.status.update_from_dps( From 4e0c11c69405737593861eb264bb3bc88607d3d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <132135057+lboue@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:52:05 +0200 Subject: [PATCH 26/32] Simplify Q10 current_option by using clean_mode.value directly --- homeassistant/components/roborock/select.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index e7770fc61b24e1..730e2a7395b3c6 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -511,13 +511,7 @@ def current_option(self) -> str | None: clean_mode = self.coordinator.api.status.clean_mode if clean_mode is None or clean_mode.code is None: return None - code = clean_mode.code - for mode in YXCleanType: - if getattr(mode, "code", None) == code: - if mode == YXCleanType.UNKNOWN: - return None - return mode.value - return None + return clean_mode.value async def async_select_option(self, option: str) -> None: """Set the cleaning mode.""" From 7e5c79ab1212584527afed2e74d41908e5eaa8dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <132135057+lboue@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:59:30 +0200 Subject: [PATCH 27/32] Fix Q10 current_option using from_code_optional to map YXDeviceWorkMode to YXCleanType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clean_mode is YXDeviceWorkMode, not YXCleanType — clean_mode.value returns "bothwork" not "vac_and_mop". Use from_code_optional to cross- reference by code and return the correct YXCleanType string value. --- homeassistant/components/roborock/select.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 730e2a7395b3c6..7fbb3946e04269 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -511,7 +511,10 @@ def current_option(self) -> str | None: clean_mode = self.coordinator.api.status.clean_mode if clean_mode is None or clean_mode.code is None: return None - return clean_mode.value + mode = YXCleanType.from_code_optional(clean_mode.code) + if mode is None or mode == YXCleanType.UNKNOWN: + return None + return mode.value async def async_select_option(self, option: str) -> None: """Set the cleaning mode.""" From fa47cdb719bd55a329e0270db85ab964e87a0ed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <132135057+lboue@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:08:22 +0200 Subject: [PATCH 28/32] Use user-facing entity key in Q10 clean mode error placeholder Replace internal API method name 'set_clean_mode' with the entity key 'cleaning_mode' as the command placeholder in the HomeAssistantError, consistent with other select entities in the same file. --- homeassistant/components/roborock/select.py | 2 +- tests/components/roborock/test_select.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 7fbb3946e04269..74dfb7127a887f 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -535,5 +535,5 @@ async def async_select_option(self, option: str) -> None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_failed", - translation_placeholders={"command": "set_clean_mode"}, + translation_placeholders={"command": "cleaning_mode"}, ) from err diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 9c9e1d5e05e9da..7290614fca02d8 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -449,7 +449,7 @@ async def test_q10_cleaning_mode_select_update_failure( entity_id = "select.roborock_q10_s5_cleaning_mode" assert hass.states.get(entity_id) is not None - with pytest.raises(HomeAssistantError, match="set_clean_mode"): + with pytest.raises(HomeAssistantError, match="cleaning_mode"): await hass.services.async_call( "select", SERVICE_SELECT_OPTION, From be6b8883e3dfc94aed82d01c499469420d30fd76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <132135057+lboue@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:13:49 +0200 Subject: [PATCH 29/32] Remove comment from options property in RoborockQ10CleanModeSelectEntity --- homeassistant/components/roborock/select.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 74dfb7127a887f..608763bf1fc4c4 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -502,7 +502,6 @@ async def async_added_to_hass(self) -> None: @property def options(self) -> list[str]: """Return available cleaning modes.""" - # Use all YXCleanType values except UNKNOWN return [mode.value for mode in YXCleanType if mode != YXCleanType.UNKNOWN] @property From 36f0d70da357f10d13278e59c30448e674d3f23a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <132135057+lboue@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:27:53 +0200 Subject: [PATCH 30/32] Simplify Q10 async_select_option using generator expression Replace manual loop with next() + walrus operator. YXCleanType is a StrEnum so members compare equal to their string values. --- homeassistant/components/roborock/select.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 608763bf1fc4c4..e074a97306457c 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -517,13 +517,7 @@ def current_option(self) -> str | None: async def async_select_option(self, option: str) -> None: """Set the cleaning mode.""" - # Find enum by value (string), not by constructor - mode = None - for m in YXCleanType: - if m.value == option: - mode = m - break - if mode is None: + if (mode := next((m for m in YXCleanType if m == option), None)) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="select_option_failed", From a5e0f5b7ebdb4793253ba35aca273edc0b74c419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <132135057+lboue@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:59:33 +0200 Subject: [PATCH 31/32] Use from_value in Q10 async_select_option for clearer option lookup --- homeassistant/components/roborock/select.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index e074a97306457c..267f08301e4ac6 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -517,11 +517,13 @@ def current_option(self) -> str | None: async def async_select_option(self, option: str) -> None: """Set the cleaning mode.""" - if (mode := next((m for m in YXCleanType if m == option), None)) is None: + try: + mode = YXCleanType.from_value(option) + except ValueError as err: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="select_option_failed", - ) from None + ) from err try: await self.coordinator.api.vacuum.set_clean_mode(mode) except RoborockException as err: From 4eaf6c0a8ff96f59bf899a430f8d4a80bde8d57d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <132135057+lboue@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:10:25 +0200 Subject: [PATCH 32/32] Simplify Q10 current_option now that clean_mode is typed as YXCleanType Q10Status.clean_mode is now correctly typed as YXCleanType in the library, so the intermediate from_code_optional lookup is no longer needed. --- homeassistant/components/roborock/select.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 267f08301e4ac6..27305b7db2c794 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -508,12 +508,9 @@ def options(self) -> list[str]: def current_option(self) -> str | None: """Get the current cleaning mode.""" clean_mode = self.coordinator.api.status.clean_mode - if clean_mode is None or clean_mode.code is None: + if clean_mode is None or clean_mode == YXCleanType.UNKNOWN: return None - mode = YXCleanType.from_code_optional(clean_mode.code) - if mode is None or mode == YXCleanType.UNKNOWN: - return None - return mode.value + return clean_mode.value async def async_select_option(self, option: str) -> None: """Set the cleaning mode."""