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_boolean,
input_button, input_button,
input_select, input_select,
lawn_mower,
light, light,
lock, lock,
media_player, media_player,
@ -58,6 +59,7 @@ DEFAULT_EXPOSED_DOMAINS = [
"humidifier", "humidifier",
"input_boolean", "input_boolean",
"input_select", "input_select",
"lawn_mower",
"light", "light",
"lock", "lock",
"media_player", "media_player",
@ -88,6 +90,7 @@ TYPE_GATE = f"{PREFIX_TYPES}GATE"
TYPE_HUMIDIFIER = f"{PREFIX_TYPES}HUMIDIFIER" TYPE_HUMIDIFIER = f"{PREFIX_TYPES}HUMIDIFIER"
TYPE_LIGHT = f"{PREFIX_TYPES}LIGHT" TYPE_LIGHT = f"{PREFIX_TYPES}LIGHT"
TYPE_LOCK = f"{PREFIX_TYPES}LOCK" TYPE_LOCK = f"{PREFIX_TYPES}LOCK"
TYPE_MOWER = f"{PREFIX_TYPES}MOWER"
TYPE_OUTLET = f"{PREFIX_TYPES}OUTLET" TYPE_OUTLET = f"{PREFIX_TYPES}OUTLET"
TYPE_RECEIVER = f"{PREFIX_TYPES}AUDIO_VIDEO_RECEIVER" TYPE_RECEIVER = f"{PREFIX_TYPES}AUDIO_VIDEO_RECEIVER"
TYPE_SCENE = f"{PREFIX_TYPES}SCENE" TYPE_SCENE = f"{PREFIX_TYPES}SCENE"
@ -149,6 +152,7 @@ DOMAIN_TO_GOOGLE_TYPES = {
input_boolean.DOMAIN: TYPE_SWITCH, input_boolean.DOMAIN: TYPE_SWITCH,
input_button.DOMAIN: TYPE_SCENE, input_button.DOMAIN: TYPE_SCENE,
input_select.DOMAIN: TYPE_SENSOR, input_select.DOMAIN: TYPE_SENSOR,
lawn_mower.DOMAIN: TYPE_MOWER,
light.DOMAIN: TYPE_LIGHT, light.DOMAIN: TYPE_LIGHT,
lock.DOMAIN: TYPE_LOCK, lock.DOMAIN: TYPE_LOCK,
media_player.DOMAIN: TYPE_SETTOP, media_player.DOMAIN: TYPE_SETTOP,

View File

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

View File

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

View File

@ -21,6 +21,7 @@ from homeassistant.components import (
input_boolean, input_boolean,
input_button, input_button,
input_select, input_select,
lawn_mower,
light, light,
lock, lock,
media_player, 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 import const, error, helpers, trait
from homeassistant.components.google_assistant.error import SmartHomeError from homeassistant.components.google_assistant.error import SmartHomeError
from homeassistant.components.humidifier import HumidifierEntityFeature from homeassistant.components.humidifier import HumidifierEntityFeature
from homeassistant.components.lawn_mower import LawnMowerEntityFeature
from homeassistant.components.light import LightEntityFeature from homeassistant.components.light import LightEntityFeature
from homeassistant.components.lock import LockEntityFeature from homeassistant.components.lock import LockEntityFeature
from homeassistant.components.media_player import ( 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"} 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( @pytest.mark.parametrize(
( (
"domain", "domain",