Add lawn mower support to Google Assistant (#140530)

* Add lawn mower support to google assistant

* Update snapshots

* Sort alphabetically

* Refactor service call

* Refactor service call

* Feedback
This commit is contained in:
Paul Bottein 2025-03-14 16:22:23 +01:00 committed by GitHub
parent e740e341c8
commit 324f208d68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 141 additions and 42 deletions

View File

@ -14,6 +14,7 @@ from homeassistant.components import (
input_boolean,
input_button,
input_select,
lawn_mower,
light,
lock,
media_player,
@ -58,6 +59,7 @@ DEFAULT_EXPOSED_DOMAINS = [
"humidifier",
"input_boolean",
"input_select",
"lawn_mower",
"light",
"lock",
"media_player",
@ -88,6 +90,7 @@ TYPE_GATE = f"{PREFIX_TYPES}GATE"
TYPE_HUMIDIFIER = f"{PREFIX_TYPES}HUMIDIFIER"
TYPE_LIGHT = f"{PREFIX_TYPES}LIGHT"
TYPE_LOCK = f"{PREFIX_TYPES}LOCK"
TYPE_MOWER = f"{PREFIX_TYPES}MOWER"
TYPE_OUTLET = f"{PREFIX_TYPES}OUTLET"
TYPE_RECEIVER = f"{PREFIX_TYPES}AUDIO_VIDEO_RECEIVER"
TYPE_SCENE = f"{PREFIX_TYPES}SCENE"
@ -149,6 +152,7 @@ DOMAIN_TO_GOOGLE_TYPES = {
input_boolean.DOMAIN: TYPE_SWITCH,
input_button.DOMAIN: TYPE_SCENE,
input_select.DOMAIN: TYPE_SENSOR,
lawn_mower.DOMAIN: TYPE_MOWER,
light.DOMAIN: TYPE_LIGHT,
lock.DOMAIN: TYPE_LOCK,
media_player.DOMAIN: TYPE_SETTOP,

View File

@ -21,6 +21,7 @@ from homeassistant.components import (
input_boolean,
input_button,
input_select,
lawn_mower,
light,
lock,
media_player,
@ -42,6 +43,7 @@ from homeassistant.components.climate import ClimateEntityFeature
from homeassistant.components.cover import CoverEntityFeature
from homeassistant.components.fan import FanEntityFeature
from homeassistant.components.humidifier import HumidifierEntityFeature
from homeassistant.components.lawn_mower import LawnMowerEntityFeature
from homeassistant.components.light import LightEntityFeature
from homeassistant.components.lock import LockState
from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType
@ -714,7 +716,7 @@ class DockTrait(_Trait):
@staticmethod
def supported(domain, features, device_class, _):
"""Test if state is supported."""
return domain == vacuum.DOMAIN
return domain in (vacuum.DOMAIN, lawn_mower.DOMAIN)
def sync_attributes(self) -> dict[str, Any]:
"""Return dock attributes for a sync request."""
@ -722,17 +724,32 @@ class DockTrait(_Trait):
def query_attributes(self) -> dict[str, Any]:
"""Return dock query attributes."""
return {"isDocked": self.state.state == vacuum.VacuumActivity.DOCKED}
domain = self.state.domain
state = self.state.state
if domain == vacuum.DOMAIN:
return {"isDocked": state == vacuum.VacuumActivity.DOCKED}
if domain == lawn_mower.DOMAIN:
return {"isDocked": state == lawn_mower.LawnMowerActivity.DOCKED}
raise NotImplementedError(f"Unsupported domain {domain}")
async def execute(self, command, data, params, challenge):
"""Execute a dock command."""
await self.hass.services.async_call(
self.state.domain,
vacuum.SERVICE_RETURN_TO_BASE,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=not self.config.should_report_state,
context=data.context,
)
domain = self.state.domain
service: str | None = None
if domain == vacuum.DOMAIN:
service = vacuum.SERVICE_RETURN_TO_BASE
elif domain == lawn_mower.DOMAIN:
service = lawn_mower.SERVICE_DOCK
if service:
await self.hass.services.async_call(
self.state.domain,
service,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=not self.config.should_report_state,
context=data.context,
)
@register_trait
@ -843,7 +860,7 @@ class StartStopTrait(_Trait):
@staticmethod
def supported(domain, features, device_class, _):
"""Test if state is supported."""
if domain == vacuum.DOMAIN:
if domain in (vacuum.DOMAIN, lawn_mower.DOMAIN):
return True
if (
@ -863,6 +880,12 @@ class StartStopTrait(_Trait):
& VacuumEntityFeature.PAUSE
!= 0
}
if domain == lawn_mower.DOMAIN:
return {
"pausable": self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
& LawnMowerEntityFeature.PAUSE
!= 0
}
if domain in COVER_VALVE_DOMAINS:
return {}
@ -878,6 +901,11 @@ class StartStopTrait(_Trait):
"isRunning": state == vacuum.VacuumActivity.CLEANING,
"isPaused": state == vacuum.VacuumActivity.PAUSED,
}
if domain == lawn_mower.DOMAIN:
return {
"isRunning": state == lawn_mower.LawnMowerActivity.MOWING,
"isPaused": state == lawn_mower.LawnMowerActivity.PAUSED,
}
if domain in COVER_VALVE_DOMAINS:
return {
@ -896,46 +924,52 @@ class StartStopTrait(_Trait):
if domain == vacuum.DOMAIN:
await self._execute_vacuum(command, data, params, challenge)
return
if domain == lawn_mower.DOMAIN:
await self._execute_lawn_mower(command, data, params, challenge)
return
if domain in COVER_VALVE_DOMAINS:
await self._execute_cover_or_valve(command, data, params, challenge)
return
async def _execute_vacuum(self, command, data, params, challenge):
"""Execute a StartStop command."""
service: str | None = None
if command == COMMAND_START_STOP:
if params["start"]:
await self.hass.services.async_call(
self.state.domain,
vacuum.SERVICE_START,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=not self.config.should_report_state,
context=data.context,
)
else:
await self.hass.services.async_call(
self.state.domain,
vacuum.SERVICE_STOP,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=not self.config.should_report_state,
context=data.context,
)
service = vacuum.SERVICE_START if params["start"] else vacuum.SERVICE_STOP
elif command == COMMAND_PAUSE_UNPAUSE:
if params["pause"]:
await self.hass.services.async_call(
self.state.domain,
vacuum.SERVICE_PAUSE,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=not self.config.should_report_state,
context=data.context,
)
else:
await self.hass.services.async_call(
self.state.domain,
vacuum.SERVICE_START,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=not self.config.should_report_state,
context=data.context,
)
service = vacuum.SERVICE_PAUSE if params["pause"] else vacuum.SERVICE_START
if service:
await self.hass.services.async_call(
self.state.domain,
service,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=not self.config.should_report_state,
context=data.context,
)
async def _execute_lawn_mower(self, command, data, params, challenge):
"""Execute a StartStop command."""
service: str | None = None
if command == COMMAND_START_STOP:
service = (
lawn_mower.SERVICE_START_MOWING
if params["start"]
else lawn_mower.SERVICE_DOCK
)
elif command == COMMAND_PAUSE_UNPAUSE:
service = (
lawn_mower.SERVICE_PAUSE
if params["pause"]
else lawn_mower.SERVICE_START_MOWING
)
if service:
await self.hass.services.async_call(
self.state.domain,
service,
{ATTR_ENTITY_ID: self.state.entity_id},
blocking=not self.config.should_report_state,
context=data.context,
)
async def _execute_cover_or_valve(self, command, data, params, challenge):
"""Execute a StartStop command."""

View File

@ -98,6 +98,7 @@
'humidifier',
'input_boolean',
'input_select',
'lawn_mower',
'light',
'lock',
'media_player',

View File

@ -21,6 +21,7 @@ from homeassistant.components import (
input_boolean,
input_button,
input_select,
lawn_mower,
light,
lock,
media_player,
@ -44,6 +45,7 @@ from homeassistant.components.fan import FanEntityFeature
from homeassistant.components.google_assistant import const, error, helpers, trait
from homeassistant.components.google_assistant.error import SmartHomeError
from homeassistant.components.humidifier import HumidifierEntityFeature
from homeassistant.components.lawn_mower import LawnMowerEntityFeature
from homeassistant.components.light import LightEntityFeature
from homeassistant.components.lock import LockEntityFeature
from homeassistant.components.media_player import (
@ -589,6 +591,64 @@ async def test_startstop_vacuum(hass: HomeAssistant) -> None:
assert unpause_calls[0].data == {ATTR_ENTITY_ID: "vacuum.bla"}
async def test_dock_lawn_mower(hass: HomeAssistant) -> None:
"""Test dock trait support for lawn mower domain."""
assert helpers.get_google_type(lawn_mower.DOMAIN, None) is not None
assert trait.DockTrait.supported(lawn_mower.DOMAIN, 0, None, None)
trt = trait.DockTrait(
hass, State("lawn_mower.bla", lawn_mower.LawnMowerActivity.MOWING), BASIC_CONFIG
)
assert trt.sync_attributes() == {}
assert trt.query_attributes() == {"isDocked": False}
calls = async_mock_service(hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_DOCK)
await trt.execute(trait.COMMAND_DOCK, BASIC_DATA, {}, {})
assert len(calls) == 1
assert calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"}
async def test_startstop_lawn_mower(hass: HomeAssistant) -> None:
"""Test startStop trait support for lawn mower domain."""
assert helpers.get_google_type(lawn_mower.DOMAIN, None) is not None
assert trait.StartStopTrait.supported(lawn_mower.DOMAIN, 0, None, None)
trt = trait.StartStopTrait(
hass,
State(
"lawn_mower.bla",
lawn_mower.LawnMowerActivity.PAUSED,
{ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.PAUSE},
),
BASIC_CONFIG,
)
assert trt.sync_attributes() == {"pausable": True}
assert trt.query_attributes() == {"isRunning": False, "isPaused": True}
start_calls = async_mock_service(
hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_START_MOWING
)
await trt.execute(trait.COMMAND_START_STOP, BASIC_DATA, {"start": True}, {})
assert len(start_calls) == 1
assert start_calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"}
pause_calls = async_mock_service(hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_PAUSE)
await trt.execute(trait.COMMAND_PAUSE_UNPAUSE, BASIC_DATA, {"pause": True}, {})
assert len(pause_calls) == 1
assert pause_calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"}
unpause_calls = async_mock_service(
hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_START_MOWING
)
await trt.execute(trait.COMMAND_PAUSE_UNPAUSE, BASIC_DATA, {"pause": False}, {})
assert len(unpause_calls) == 1
assert unpause_calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"}
@pytest.mark.parametrize(
(
"domain",