Skip to content

Commit

Permalink
Roborock Add vacuum_goto service (#133994)
Browse files Browse the repository at this point in the history
* Roborock Add vacuum_goto service to control vacuum movement to specified coordinates

* roborock Add type specification for x_coord and y_coord in vacuum_goto service

* roborock Add get_current_position service to retrieve vacuum's current coordinates

* Rename vacuum services for clarity and consistency

* Apply suggestions from code review

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Add integration field to vacuum service targets for Roborock

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
  • Loading branch information
RaHehl and gjohansson-ST authored Dec 26, 2024
1 parent 9840785 commit b2a160d
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 4 deletions.
2 changes: 2 additions & 0 deletions homeassistant/components/roborock/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 6 additions & 0 deletions homeassistant/components/roborock/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
24 changes: 24 additions & 0 deletions homeassistant/components/roborock/services.yaml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions homeassistant/components/roborock/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
53 changes: 51 additions & 2 deletions homeassistant/components/roborock/vacuum.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,28 @@
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,
VacuumActivity,
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"
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand All @@ -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,
}
118 changes: 116 additions & 2 deletions tests/components/roborock/test_vacuum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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,
)

0 comments on commit b2a160d

Please sign in to comment.