From 324f208d68f3d577efa9261edb47def1f6817d41 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 14 Mar 2025 16:22:23 +0100 Subject: [PATCH] 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 --- .../components/google_assistant/const.py | 4 + .../components/google_assistant/trait.py | 118 +++++++++++------- .../snapshots/test_diagnostics.ambr | 1 + .../components/google_assistant/test_trait.py | 60 +++++++++ 4 files changed, 141 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 8132ecaae2c..71738c9d13e 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -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, diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 44251a3be04..9edd340d7d9 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -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.""" diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index 1ecedbd1173..cc5ccbb1de1 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -98,6 +98,7 @@ 'humidifier', 'input_boolean', 'input_select', + 'lawn_mower', 'light', 'lock', 'media_player', diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 1fc4a0e3a0c..cf9c8047049 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -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",