mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 09:23:17 -04:00
Add Roborock Q10 position, zone cleaning, and goto support (#177780)
Co-authored-by: Ludovic BOUÉ <lboue@users.noreply.github.com>
This commit is contained in:
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user