From 09e3474f91be03d4ac5a70a698474986fc759bcf Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:00:07 +0200 Subject: [PATCH] Add Roborock Q10 position, zone cleaning, and goto support (#177780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ --- .../components/roborock/strings.json | 6 + homeassistant/components/roborock/vacuum.py | 45 +++++- tests/components/roborock/test_vacuum.py | 151 +++++++++++++++++- 3 files changed, 195 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index a914ef16720f..4c2d8c9dd96e 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -731,6 +731,12 @@ "invalid_fan_speed": { "message": "Invalid fan speed: {fan_speed}" }, + "invalid_q10_coordinate": { + "message": "Coordinates ({x}, {y}) are not valid for this vacuum" + }, + "invalid_q10_zone": { + "message": "The zone coordinates are not valid for 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 ee8ba8bb4968..bd4607d9449d 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -9,6 +9,7 @@ from roborock.data.b01_q10.b01_q10_code_mappings import ( YXDeviceState, YXFanLevel, ) +from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint from roborock.exceptions import RoborockException from roborock.roborock_typing import RoborockCommand @@ -779,14 +780,50 @@ class RoborockQ10Vacuum(RoborockCoordinatedEntityB01Q10, StateVacuumEntity): 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.robot_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 the library goto operation.""" + try: + await self.coordinator.api.vacuum.goto_position(Q10RoborockPoint(x, y)) + 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 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) + try: + # Home Assistant defines repeats as additional passes, while Q10 + # carries the total clean count. + await self.coordinator.api.vacuum.clean_zone( + Q10RoborockPoint(x1, y1), + Q10RoborockPoint(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 68069d850196..796d13d7081f 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -8,6 +8,7 @@ import pytest from roborock import RoborockException from roborock.data import WorkStatusMapping from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXFanLevel +from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint from roborock.roborock_typing import RoborockCommand from syrupy.assertion import SnapshotAssertion from vacuum_map_parser_base.map_data import Point @@ -299,7 +300,6 @@ async def test_goto( "entity_id", [ Q7_ENTITY_ID, - Q10_ENTITY_ID, ], ) async def test_goto_not_supported( @@ -349,7 +349,6 @@ async def test_zoned_cleaning( "entity_id", [ Q7_ENTITY_ID, - Q10_ENTITY_ID, ], ) async def test_zoned_cleaning_not_supported( @@ -447,7 +446,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( @@ -928,10 +926,155 @@ 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 +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.robot_position = Q10RoborockPoint(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.robot_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": 28580, + "y1": 21365, + "x2": 27425, + "y2": 22815, + "repeats": 1, + }, + blocking=True, + ) + + assert q10_vacuum_api.vacuum.clean_zone.call_args == call( + Q10RoborockPoint(28580, 21365), + Q10RoborockPoint(27425, 22815), + clean_count=2, + ) + + +async def test_q10_goto( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q10_vacuum_api: Mock, +) -> None: + """Test that Q10 goto delegates the complete operation to the library.""" + q10_vacuum_api.map.robot_position = Q10RoborockPoint(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.goto_position.call_args == call( + Q10RoborockPoint(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, @@ -1044,6 +1187,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_not_called() async def test_q10_send_command_invalid( @@ -1062,6 +1206,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(