diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 834b25965c3a83..4a9bd14bfe1f9f 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -49,3 +49,5 @@ MAP_SLEEP = 3 GET_MAPS_SERVICE_NAME = "get_maps" +SET_VACUUM_GOTO_POSITION_SERVICE_NAME = "set_vacuum_goto_position" +GET_VACUUM_CURRENT_POSITION_SERVICE_NAME = "get_vacuum_current_position" diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json index c7df6d35460b58..15414cfc2d9d86 100644 --- a/homeassistant/components/roborock/icons.json +++ b/homeassistant/components/roborock/icons.json @@ -121,6 +121,12 @@ "services": { "get_maps": { "service": "mdi:floor-plan" + }, + "set_vacuum_goto_position": { + "service": "mdi:map-marker" + }, + "get_vacuum_current_position": { + "service": "mdi:map-marker" } } } diff --git a/homeassistant/components/roborock/services.yaml b/homeassistant/components/roborock/services.yaml index 18de5c98c7bbc4..4d4292bedaff3d 100644 --- a/homeassistant/components/roborock/services.yaml +++ b/homeassistant/components/roborock/services.yaml @@ -1,4 +1,28 @@ get_maps: target: entity: + integration: roborock + domain: vacuum +set_vacuum_goto_position: + target: + entity: + integration: roborock + domain: vacuum + fields: + x_coord: + example: 27500 + required: true + selector: + text: + type: number + y_coord: + example: 32000 + required: true + selector: + text: + type: number +get_vacuum_current_position: + target: + entity: + integration: roborock domain: vacuum diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 8ff82cae393166..354206ec955d7e 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -428,6 +428,24 @@ "get_maps": { "name": "Get maps", "description": "Get the map and room information of your device." + }, + "set_vacuum_goto_position": { + "name": "Go to position", + "description": "Send the vacuum to a specific position.", + "fields": { + "x_coord": { + "name": "X-coordinate", + "description": "" + }, + "y_coord": { + "name": "Y-coordinate", + "description": "" + } + } + }, + "get_vacuum_current_position": { + "name": "Get current position", + "description": "Get the current position of the vacuum." } } } diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index d3413bd7cbdd70..b15e961869db01 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -6,6 +6,7 @@ from roborock.code_mappings import RoborockStateCode from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand +import voluptuous as vol from homeassistant.components.vacuum import ( StateVacuumEntity, @@ -13,13 +14,20 @@ VacuumEntityFeature, ) from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse -from homeassistant.helpers import entity_platform +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RoborockConfigEntry -from .const import DOMAIN, GET_MAPS_SERVICE_NAME +from .const import ( + DOMAIN, + GET_MAPS_SERVICE_NAME, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + SET_VACUUM_GOTO_POSITION_SERVICE_NAME, +) from .coordinator import RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 +from .image import ColorsPalette, ImageConfig, RoborockMapDataParser, Sizes STATE_CODE_TO_STATE = { RoborockStateCode.starting: VacuumActivity.IDLE, # "Starting" @@ -69,6 +77,25 @@ async def async_setup_entry( supports_response=SupportsResponse.ONLY, ) + platform.async_register_entity_service( + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + None, + RoborockVacuum.get_vacuum_current_position.__name__, + supports_response=SupportsResponse.ONLY, + ) + + platform.async_register_entity_service( + SET_VACUUM_GOTO_POSITION_SERVICE_NAME, + cv.make_entity_service_schema( + { + vol.Required("x_coord"): vol.Coerce(int), + vol.Required("y_coord"): vol.Coerce(int), + }, + ), + RoborockVacuum.async_set_vacuum_goto_position.__name__, + supports_response=SupportsResponse.NONE, + ) + class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): """General Representation of a Roborock vacuum.""" @@ -158,6 +185,10 @@ async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: [self._device_status.get_fan_speed_code(fan_speed)], ) + async def async_set_vacuum_goto_position(self, x_coord: int, y_coord: int) -> None: + """Send vacuum to a specific target point.""" + await self.send(RoborockCommand.APP_GOTO_TARGET, [x_coord, y_coord]) + async def async_send_command( self, command: str, @@ -174,3 +205,21 @@ async def get_maps(self) -> ServiceResponse: asdict(vacuum_map) for vacuum_map in self.coordinator.maps.values() ] } + + async def get_vacuum_current_position(self) -> ServiceResponse: + """Get the current position of the vacuum from the map.""" + + map_data = await self.coordinator.cloud_api.get_map_v1() + if not isinstance(map_data, bytes): + raise HomeAssistantError("Failed to retrieve map data.") + parser = RoborockMapDataParser(ColorsPalette(), Sizes(), [], ImageConfig(), []) + parsed_map = parser.parse(map_data) + robot_position = parsed_map.vacuum_position + + if robot_position is None: + raise HomeAssistantError("Robot position not found") + + return { + "x": robot_position.x, + "y": robot_position.y, + } diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 5080711d0f9792..e0872dca40f34d 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -8,9 +8,14 @@ from roborock import RoborockException from roborock.roborock_typing import RoborockCommand from syrupy.assertion import SnapshotAssertion +from vacuum_map_parser_base.map_data import Point from homeassistant.components.roborock import DOMAIN -from homeassistant.components.roborock.const import GET_MAPS_SERVICE_NAME +from homeassistant.components.roborock.const import ( + GET_MAPS_SERVICE_NAME, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + SET_VACUUM_GOTO_POSITION_SERVICE_NAME, +) from homeassistant.components.vacuum import ( SERVICE_CLEAN_SPOT, SERVICE_LOCATE, @@ -27,7 +32,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .mock_data import PROP +from .mock_data import MAP_DATA, PROP from tests.common import MockConfigEntry @@ -181,3 +186,112 @@ async def test_get_maps( return_response=True, ) assert response == snapshot + + +async def test_goto( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, +) -> None: + """Test sending the vacuum to specific coordinates.""" + vacuum = hass.states.get(ENTITY_ID) + assert vacuum + + data = {ATTR_ENTITY_ID: ENTITY_ID, "x_coord": 25500, "y_coord": 25500} + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_command" + ) as mock_send_command: + await hass.services.async_call( + DOMAIN, + SET_VACUUM_GOTO_POSITION_SERVICE_NAME, + data, + blocking=True, + ) + assert mock_send_command.call_count == 1 + assert mock_send_command.call_args[0][0] == RoborockCommand.APP_GOTO_TARGET + assert mock_send_command.call_args[0][1] == [25500, 25500] + + +async def test_get_current_position( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test that the service for getting the current position outputs the correct coordinates.""" + map_data = copy.deepcopy(MAP_DATA) + map_data.vacuum_position = Point(x=123, y=456) + map_data.image = None + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", + return_value=b"", + ), + patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=map_data, + ), + ): + response = await hass.services.async_call( + DOMAIN, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + return_response=True, + ) + assert response == { + "vacuum.roborock_s7_maxv": { + "x": 123, + "y": 456, + }, + } + + +async def test_get_current_position_no_map_data( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, +) -> None: + """Test that the service for getting the current position handles no map data error.""" + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", + return_value=None, + ), + pytest.raises(HomeAssistantError, match="Failed to retrieve map data."), + ): + await hass.services.async_call( + DOMAIN, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + return_response=True, + ) + + +async def test_get_current_position_no_robot_position( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, +) -> None: + """Test that the service for getting the current position handles no robot position error.""" + map_data = copy.deepcopy(MAP_DATA) + map_data.vacuum_position = None + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", + return_value=b"", + ), + patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=map_data, + ), + pytest.raises(HomeAssistantError, match="Robot position not found"), + ): + await hass.services.async_call( + DOMAIN, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + return_response=True, + )