From 32f908cd352edcb66a2468117d8e1a72120a6032 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:39:00 +0200 Subject: [PATCH 1/3] Add Q10 position, zone cleaning, and goto support --- .../components/roborock/strings.json | 6 + homeassistant/components/roborock/vacuum.py | 119 +++++++++++++++- tests/components/roborock/test_vacuum.py | 127 +++++++++++++++++- 3 files changed, 244 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 764267178ad11c..7b17208013edf5 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -691,6 +691,12 @@ "invalid_fan_speed": { "message": "Invalid fan speed: {fan_speed}" }, + "invalid_q10_coordinate": { + "message": "Coordinates ({x}, {y}) are outside the range supported by this vacuum" + }, + "invalid_q10_zone": { + "message": "The zone coordinates are outside the range supported by this vacuum" + }, "invalid_user_agreement": { "message": "User agreement must be accepted again. Open your Roborock app and accept the agreement." }, diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 853572e4ff9765..6af1e9bc3682f0 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -1,6 +1,8 @@ """Support for Roborock vacuum class.""" +import asyncio import logging +from math import hypot from typing import Any, override from roborock.data import RoborockStateCode, SCWindMapping, WorkStatusMapping @@ -107,6 +109,10 @@ PARALLEL_UPDATES = 0 +Q10_GOTO_HALF_ZONE_SIZE = 200 +Q10_GOTO_TOLERANCE = 200 +Q10_GOTO_TIMEOUT = 300 + async def async_setup_entry( hass: HomeAssistant, @@ -596,15 +602,60 @@ def __init__( coordinator.duid_slug, coordinator, ) + self._goto_monitor_task: asyncio.Task[None] | None = None @override 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._cancel_goto_monitor) self.async_on_remove( self.coordinator.api.status.add_update_listener(self.async_write_ha_state) ) + def _cancel_goto_monitor(self) -> None: + """Cancel a pending emulated goto monitor.""" + if self._goto_monitor_task is not None: + self._goto_monitor_task.cancel() + self._goto_monitor_task = None + + async def _async_monitor_goto_target(self, x: int, y: int) -> None: + """Pause the Q10 mini-zone task when the robot reaches its target.""" + current_task = asyncio.current_task() + try: + async with asyncio.timeout(Q10_GOTO_TIMEOUT): + while True: + if ( + position := self.coordinator.api.map.roborock_position + ) is not None and hypot(position.x - x, position.y - y) <= ( + Q10_GOTO_TOLERANCE + ): + _LOGGER.debug( + "Q10 vacuum reached goto target (%s, %s); pausing zone task", + x, + y, + ) + await self.coordinator.api.vacuum.pause_clean() + return + await asyncio.sleep(1) + except TimeoutError: + _LOGGER.warning( + "Q10 vacuum did not report reaching goto target (%s, %s) within " + "%s seconds; stopping zone task", + x, + y, + Q10_GOTO_TIMEOUT, + ) + try: + await self.coordinator.api.vacuum.stop_clean() + except RoborockException as err: + _LOGGER.warning("Failed to stop timed-out Q10 goto task: %s", err) + except RoborockException as err: + _LOGGER.warning("Failed to pause completed Q10 goto task: %s", err) + finally: + if self._goto_monitor_task is current_task: + self._goto_monitor_task = None + @property @override def activity(self) -> VacuumActivity | None: @@ -624,6 +675,7 @@ def fan_speed(self) -> str | None: @override async def async_start(self) -> None: """Start the vacuum.""" + self._cancel_goto_monitor() try: await self.coordinator.api.vacuum.start_clean() except RoborockException as err: @@ -638,6 +690,7 @@ async def async_start(self) -> None: @override async def async_pause(self) -> None: """Pause the vacuum.""" + self._cancel_goto_monitor() try: await self.coordinator.api.vacuum.pause_clean() except RoborockException as err: @@ -652,6 +705,7 @@ async def async_pause(self) -> None: @override async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum.""" + self._cancel_goto_monitor() try: await self.coordinator.api.vacuum.stop_clean() except RoborockException as err: @@ -666,6 +720,7 @@ async def async_stop(self, **kwargs: Any) -> None: @override async def async_return_to_base(self, **kwargs: Any) -> None: """Send vacuum back to base.""" + self._cancel_goto_monitor() try: await self.coordinator.api.vacuum.return_to_dock() except RoborockException as err: @@ -726,6 +781,7 @@ async def async_get_segments(self) -> list[Segment]: @override async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: """Clean the specified segments.""" + self._cancel_goto_monitor() try: await self.coordinator.api.vacuum.clean_segments( [int(seg_id) for seg_id in segment_ids] @@ -751,6 +807,7 @@ async def async_send_command( The command string can be an enum name (e.g. "SEEK"), a DP string value (e.g. "dpSeek"), or an integer code (e.g. "11"). """ + self._cancel_goto_monitor() if (dp_command := B01_Q10_DP.from_any_optional(command)) is None: raise ServiceValidationError( translation_domain=DOMAIN, @@ -776,14 +833,68 @@ async def get_maps(self) -> ServiceResponse: async def get_vacuum_current_position(self) -> ServiceResponse: """Get the current position of the vacuum from the map.""" - raise ServiceNotSupported(DOMAIN, "get_vacuum_current_position", self.entity_id) + if (position := self.coordinator.api.map.roborock_position) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="position_not_found", + ) + return {"x": position.x, "y": position.y} async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: - """Set the vacuum to go to a specific position.""" - raise ServiceNotSupported(DOMAIN, "set_vacuum_goto_position", self.entity_id) + """Move the Q10 to a position using a small zone-clean task.""" + self._cancel_goto_monitor() + if (position := self.coordinator.api.map.roborock_position) is not None: + if hypot(position.x - x, position.y - y) <= Q10_GOTO_TOLERANCE: + return + + try: + await self.coordinator.api.vacuum.clean_zone( + x - Q10_GOTO_HALF_ZONE_SIZE, + y - Q10_GOTO_HALF_ZONE_SIZE, + x + Q10_GOTO_HALF_ZONE_SIZE, + y + Q10_GOTO_HALF_ZONE_SIZE, + ) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_q10_coordinate", + translation_placeholders={"x": str(x), "y": str(y)}, + ) from err + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={"command": "set_vacuum_goto_position"}, + ) from err + + self._goto_monitor_task = self.hass.async_create_task( + self._async_monitor_goto_target(x, y), + f"roborock_q10_goto_{self.coordinator.duid_slug}", + ) async def async_set_vacuum_zoned_cleaning( self, x1: int, y1: int, x2: int, y2: int, repeats: int ) -> None: """Clean the specified zone.""" - raise ServiceNotSupported(DOMAIN, "set_vacuum_zoned_cleaning", self.entity_id) + self._cancel_goto_monitor() + try: + # Home Assistant defines repeats as additional passes, while Q10 + # carries the total clean count. + await self.coordinator.api.vacuum.clean_zone( + x1, + y1, + x2, + y2, + clean_count=repeats + 1, + ) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_q10_zone", + ) from err + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={"command": "set_vacuum_zoned_cleaning"}, + ) from err diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 00980de44328ab..7c7cc942f51473 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -1,12 +1,15 @@ """Tests for Roborock vacuums.""" +import asyncio from datetime import timedelta +from types import SimpleNamespace from typing import Any -from unittest.mock import Mock, call +from unittest.mock import AsyncMock, Mock, call import pytest from roborock import RoborockException from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXFanLevel +from roborock.map.b01_q10_map_parser import Q10Point from roborock.roborock_typing import RoborockCommand from syrupy.assertion import SnapshotAssertion from vacuum_map_parser_base.map_data import Point @@ -22,6 +25,7 @@ SET_VACUUM_GOTO_POSITION_SERVICE_NAME, SET_VACUUM_ZONED_CLEANING_SERVICE_NAME, ) +from homeassistant.components.roborock.vacuum import RoborockQ10Vacuum from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_CLEAN_AREA, @@ -298,7 +302,6 @@ async def test_goto( "entity_id", [ Q7_ENTITY_ID, - Q10_ENTITY_ID, ], ) async def test_goto_not_supported( @@ -348,7 +351,6 @@ async def test_zoned_cleaning( "entity_id", [ Q7_ENTITY_ID, - Q10_ENTITY_ID, ], ) async def test_zoned_cleaning_not_supported( @@ -446,7 +448,6 @@ async def test_get_current_position_no_robot_position( "entity_id", [ Q7_ENTITY_ID, - Q10_ENTITY_ID, ], ) async def test_get_current_position_not_supported( @@ -909,6 +910,124 @@ def fake_q10_vacuum_api_fixture( return api +async def test_q10_get_current_position( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q10_vacuum_api: Mock, +) -> None: + """Test returning the Q10 position in common Roborock coordinates.""" + q10_vacuum_api.map.roborock_position = Q10Point(x=30020, y=28705) + + response = await hass.services.async_call( + DOMAIN, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + {ATTR_ENTITY_ID: Q10_ENTITY_ID}, + blocking=True, + return_response=True, + ) + + assert response == {Q10_ENTITY_ID: {"x": 30020, "y": 28705}} + + +async def test_q10_get_current_position_not_available( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q10_vacuum_api: Mock, +) -> None: + """Test the Q10 position service before a trace has been received.""" + q10_vacuum_api.map.roborock_position = None + + with pytest.raises(HomeAssistantError, match="Robot position not found"): + await hass.services.async_call( + DOMAIN, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + {ATTR_ENTITY_ID: Q10_ENTITY_ID}, + blocking=True, + return_response=True, + ) + + +async def test_q10_zoned_cleaning( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q10_vacuum_api: Mock, +) -> None: + """Test that Q10 zoned cleaning uses the native zone task.""" + await hass.services.async_call( + DOMAIN, + SET_VACUUM_ZONED_CLEANING_SERVICE_NAME, + { + ATTR_ENTITY_ID: Q10_ENTITY_ID, + "x1": 28582, + "y1": 21363, + "x2": 27425, + "y2": 22816, + "repeats": 1, + }, + blocking=True, + ) + + assert q10_vacuum_api.vacuum.clean_zone.call_args == call( + 28582, + 21363, + 27425, + 22816, + clean_count=2, + ) + + +async def test_q10_goto( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q10_vacuum_api: Mock, +) -> None: + """Test that Q10 goto starts a 40 cm zone centered on the target.""" + q10_vacuum_api.map.roborock_position = Q10Point(x=25500, y=25500) + + await hass.services.async_call( + DOMAIN, + SET_VACUUM_GOTO_POSITION_SERVICE_NAME, + {ATTR_ENTITY_ID: Q10_ENTITY_ID, "x": 29900, "y": 28650}, + blocking=True, + ) + + assert q10_vacuum_api.vacuum.clean_zone.call_args == call( + 29700, + 28450, + 30100, + 28850, + ) + + # Cancel the background arrival monitor before the test finishes. + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_STOP, + {ATTR_ENTITY_ID: Q10_ENTITY_ID}, + blocking=True, + ) + + +async def test_q10_goto_monitor_pauses_at_target() -> None: + """Test that the goto monitor pauses inside the target tolerance.""" + pause_clean = AsyncMock() + entity = SimpleNamespace( + _goto_monitor_task=asyncio.current_task(), + coordinator=SimpleNamespace( + api=SimpleNamespace( + map=SimpleNamespace( + roborock_position=Q10Point(x=30020, y=28705) + ), + vacuum=SimpleNamespace(pause_clean=pause_clean), + ) + ), + ) + + await RoborockQ10Vacuum._async_monitor_goto_target(entity, 29900, 28650) + + pause_clean.assert_awaited_once_with() + assert entity._goto_monitor_task is None + + async def test_q10_registry_entries( hass: HomeAssistant, entity_registry: er.EntityRegistry, From a8c79205d0a0d814c33e7a62f6e8545aa2c61638 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:07:04 +0200 Subject: [PATCH 2/3] refactor: delegate Q10 goto lifecycle to library --- homeassistant/components/roborock/vacuum.py | 78 +-------------------- tests/components/roborock/test_vacuum.py | 45 ++---------- 2 files changed, 8 insertions(+), 115 deletions(-) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 6af1e9bc3682f0..98c334661eae84 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -1,8 +1,6 @@ """Support for Roborock vacuum class.""" -import asyncio import logging -from math import hypot from typing import Any, override from roborock.data import RoborockStateCode, SCWindMapping, WorkStatusMapping @@ -109,10 +107,6 @@ PARALLEL_UPDATES = 0 -Q10_GOTO_HALF_ZONE_SIZE = 200 -Q10_GOTO_TOLERANCE = 200 -Q10_GOTO_TIMEOUT = 300 - async def async_setup_entry( hass: HomeAssistant, @@ -602,60 +596,15 @@ def __init__( coordinator.duid_slug, coordinator, ) - self._goto_monitor_task: asyncio.Task[None] | None = None @override 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._cancel_goto_monitor) self.async_on_remove( self.coordinator.api.status.add_update_listener(self.async_write_ha_state) ) - def _cancel_goto_monitor(self) -> None: - """Cancel a pending emulated goto monitor.""" - if self._goto_monitor_task is not None: - self._goto_monitor_task.cancel() - self._goto_monitor_task = None - - async def _async_monitor_goto_target(self, x: int, y: int) -> None: - """Pause the Q10 mini-zone task when the robot reaches its target.""" - current_task = asyncio.current_task() - try: - async with asyncio.timeout(Q10_GOTO_TIMEOUT): - while True: - if ( - position := self.coordinator.api.map.roborock_position - ) is not None and hypot(position.x - x, position.y - y) <= ( - Q10_GOTO_TOLERANCE - ): - _LOGGER.debug( - "Q10 vacuum reached goto target (%s, %s); pausing zone task", - x, - y, - ) - await self.coordinator.api.vacuum.pause_clean() - return - await asyncio.sleep(1) - except TimeoutError: - _LOGGER.warning( - "Q10 vacuum did not report reaching goto target (%s, %s) within " - "%s seconds; stopping zone task", - x, - y, - Q10_GOTO_TIMEOUT, - ) - try: - await self.coordinator.api.vacuum.stop_clean() - except RoborockException as err: - _LOGGER.warning("Failed to stop timed-out Q10 goto task: %s", err) - except RoborockException as err: - _LOGGER.warning("Failed to pause completed Q10 goto task: %s", err) - finally: - if self._goto_monitor_task is current_task: - self._goto_monitor_task = None - @property @override def activity(self) -> VacuumActivity | None: @@ -675,7 +624,6 @@ def fan_speed(self) -> str | None: @override async def async_start(self) -> None: """Start the vacuum.""" - self._cancel_goto_monitor() try: await self.coordinator.api.vacuum.start_clean() except RoborockException as err: @@ -690,7 +638,6 @@ async def async_start(self) -> None: @override async def async_pause(self) -> None: """Pause the vacuum.""" - self._cancel_goto_monitor() try: await self.coordinator.api.vacuum.pause_clean() except RoborockException as err: @@ -705,7 +652,6 @@ async def async_pause(self) -> None: @override async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum.""" - self._cancel_goto_monitor() try: await self.coordinator.api.vacuum.stop_clean() except RoborockException as err: @@ -720,7 +666,6 @@ async def async_stop(self, **kwargs: Any) -> None: @override async def async_return_to_base(self, **kwargs: Any) -> None: """Send vacuum back to base.""" - self._cancel_goto_monitor() try: await self.coordinator.api.vacuum.return_to_dock() except RoborockException as err: @@ -781,7 +726,6 @@ async def async_get_segments(self) -> list[Segment]: @override async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: """Clean the specified segments.""" - self._cancel_goto_monitor() try: await self.coordinator.api.vacuum.clean_segments( [int(seg_id) for seg_id in segment_ids] @@ -807,7 +751,6 @@ async def async_send_command( The command string can be an enum name (e.g. "SEEK"), a DP string value (e.g. "dpSeek"), or an integer code (e.g. "11"). """ - self._cancel_goto_monitor() if (dp_command := B01_Q10_DP.from_any_optional(command)) is None: raise ServiceValidationError( translation_domain=DOMAIN, @@ -826,6 +769,7 @@ async def async_send_command( "command": command, }, ) from err + self.coordinator.api.vacuum.cancel_goto() async def get_maps(self) -> ServiceResponse: """Get map information such as map id and room ids.""" @@ -841,19 +785,9 @@ async def get_vacuum_current_position(self) -> ServiceResponse: return {"x": position.x, "y": position.y} async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: - """Move the Q10 to a position using a small zone-clean task.""" - self._cancel_goto_monitor() - if (position := self.coordinator.api.map.roborock_position) is not None: - if hypot(position.x - x, position.y - y) <= Q10_GOTO_TOLERANCE: - return - + """Move the Q10 to a position using the library goto operation.""" try: - await self.coordinator.api.vacuum.clean_zone( - x - Q10_GOTO_HALF_ZONE_SIZE, - y - Q10_GOTO_HALF_ZONE_SIZE, - x + Q10_GOTO_HALF_ZONE_SIZE, - y + Q10_GOTO_HALF_ZONE_SIZE, - ) + await self.coordinator.api.vacuum.goto_position(x, y) except ValueError as err: raise ServiceValidationError( translation_domain=DOMAIN, @@ -867,16 +801,10 @@ async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: translation_placeholders={"command": "set_vacuum_goto_position"}, ) from err - self._goto_monitor_task = self.hass.async_create_task( - self._async_monitor_goto_target(x, y), - f"roborock_q10_goto_{self.coordinator.duid_slug}", - ) - async def async_set_vacuum_zoned_cleaning( self, x1: int, y1: int, x2: int, y2: int, repeats: int ) -> None: """Clean the specified zone.""" - self._cancel_goto_monitor() try: # Home Assistant defines repeats as additional passes, while Q10 # carries the total clean count. diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 7c7cc942f51473..42b2ad3dde0f3f 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -1,10 +1,8 @@ """Tests for Roborock vacuums.""" -import asyncio from datetime import timedelta -from types import SimpleNamespace from typing import Any -from unittest.mock import AsyncMock, Mock, call +from unittest.mock import Mock, call import pytest from roborock import RoborockException @@ -25,7 +23,6 @@ SET_VACUUM_GOTO_POSITION_SERVICE_NAME, SET_VACUUM_ZONED_CLEANING_SERVICE_NAME, ) -from homeassistant.components.roborock.vacuum import RoborockQ10Vacuum from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_CLEAN_AREA, @@ -981,7 +978,7 @@ async def test_q10_goto( setup_entry: MockConfigEntry, q10_vacuum_api: Mock, ) -> None: - """Test that Q10 goto starts a 40 cm zone centered on the target.""" + """Test that Q10 goto delegates the complete operation to the library.""" q10_vacuum_api.map.roborock_position = Q10Point(x=25500, y=25500) await hass.services.async_call( @@ -991,41 +988,7 @@ async def test_q10_goto( blocking=True, ) - assert q10_vacuum_api.vacuum.clean_zone.call_args == call( - 29700, - 28450, - 30100, - 28850, - ) - - # Cancel the background arrival monitor before the test finishes. - await hass.services.async_call( - VACUUM_DOMAIN, - SERVICE_STOP, - {ATTR_ENTITY_ID: Q10_ENTITY_ID}, - blocking=True, - ) - - -async def test_q10_goto_monitor_pauses_at_target() -> None: - """Test that the goto monitor pauses inside the target tolerance.""" - pause_clean = AsyncMock() - entity = SimpleNamespace( - _goto_monitor_task=asyncio.current_task(), - coordinator=SimpleNamespace( - api=SimpleNamespace( - map=SimpleNamespace( - roborock_position=Q10Point(x=30020, y=28705) - ), - vacuum=SimpleNamespace(pause_clean=pause_clean), - ) - ), - ) - - await RoborockQ10Vacuum._async_monitor_goto_target(entity, 29900, 28650) - - pause_clean.assert_awaited_once_with() - assert entity._goto_monitor_task is None + assert q10_vacuum_api.vacuum.goto_position.call_args == call(29900, 28650) async def test_q10_registry_entries( @@ -1140,6 +1103,7 @@ async def test_q10_send_command( blocking=True, ) assert q10_vacuum_api.command.send.call_count == 1 + q10_vacuum_api.vacuum.cancel_goto.assert_called_once_with() async def test_q10_send_command_invalid( @@ -1158,6 +1122,7 @@ async def test_q10_send_command_invalid( {ATTR_ENTITY_ID: Q10_ENTITY_ID, "command": "INVALID_COMMAND"}, blocking=True, ) + q10_vacuum_api.vacuum.cancel_goto.assert_not_called() @pytest.mark.parametrize( From 568370da44c4054f0365f8825c44d0c61b5db299 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:15:43 +0200 Subject: [PATCH 3/3] test: cover Q10 targeted action failures --- tests/components/roborock/test_vacuum.py | 61 ++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 42b2ad3dde0f3f..05179f6a3afb57 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -903,6 +903,8 @@ def fake_q10_vacuum_api_fixture( api.vacuum.return_to_dock.side_effect = send_message_exception api.vacuum.set_fan_level.side_effect = send_message_exception api.vacuum.clean_segments.side_effect = send_message_exception + api.vacuum.clean_zone.side_effect = send_message_exception + api.vacuum.goto_position.side_effect = send_message_exception api.command.send.side_effect = send_message_exception return api @@ -991,6 +993,65 @@ async def test_q10_goto( assert q10_vacuum_api.vacuum.goto_position.call_args == call(29900, 28650) +@pytest.mark.parametrize( + ("service", "service_data", "api_method", "error", "expected_exception"), + [ + pytest.param( + SET_VACUUM_GOTO_POSITION_SERVICE_NAME, + {"x": 29900, "y": 28650}, + "goto_position", + ValueError("invalid coordinate"), + ServiceValidationError, + id="goto-validation", + ), + pytest.param( + SET_VACUUM_GOTO_POSITION_SERVICE_NAME, + {"x": 29900, "y": 28650}, + "goto_position", + RoborockException(), + HomeAssistantError, + id="goto-command", + ), + pytest.param( + SET_VACUUM_ZONED_CLEANING_SERVICE_NAME, + {"x1": 28582, "y1": 21363, "x2": 27425, "y2": 22816, "repeats": 1}, + "clean_zone", + ValueError("invalid zone"), + ServiceValidationError, + id="zone-validation", + ), + pytest.param( + SET_VACUUM_ZONED_CLEANING_SERVICE_NAME, + {"x1": 28582, "y1": 21363, "x2": 27425, "y2": 22816, "repeats": 1}, + "clean_zone", + RoborockException(), + HomeAssistantError, + id="zone-command", + ), + ], +) +async def test_q10_targeted_action_errors( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q10_vacuum_api: Mock, + service: str, + service_data: dict[str, Any], + api_method: str, + error: Exception, + expected_exception: type[Exception], +) -> None: + """Test validation and command failures for Q10 targeted actions.""" + getattr(q10_vacuum_api.vacuum, api_method).side_effect = error + + with pytest.raises(expected_exception): + await hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: Q10_ENTITY_ID, **service_data}, + blocking=True, + ) + + async def test_q10_registry_entries( hass: HomeAssistant, entity_registry: er.EntityRegistry,