From f7a35f97c8fa8b24973174c9c04e80bc8efb2ea3 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 11 Sep 2026 11:58:12 +0200 Subject: [PATCH] Add idle state and stop action to lawn mower entity (#181188) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/kitchen_sink/lawn_mower.py | 15 +++++++++- .../components/lawn_mower/__init__.py | 12 ++++++++ .../components/lawn_mower/condition.py | 1 + .../components/lawn_mower/conditions.yaml | 1 + homeassistant/components/lawn_mower/const.py | 5 ++++ .../components/lawn_mower/icons.json | 9 ++++++ .../components/lawn_mower/services.yaml | 7 +++++ .../components/lawn_mower/strings.json | 29 +++++++++++++++++++ .../components/lawn_mower/trigger.py | 1 + .../components/lawn_mower/triggers.yaml | 1 + .../snapshots/test_lawn_mower.ambr | 14 ++++++++- .../kitchen_sink/test_lawn_mower.py | 8 +++++ tests/components/lawn_mower/test_condition.py | 12 ++++++++ tests/components/lawn_mower/test_init.py | 11 +++++++ tests/components/lawn_mower/test_trigger.py | 17 +++++++++++ tests/components/mqtt/test_lawn_mower.py | 4 +-- 16 files changed, 143 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/kitchen_sink/lawn_mower.py b/homeassistant/components/kitchen_sink/lawn_mower.py index e33f670420b3..5b9f5511d18f 100644 --- a/homeassistant/components/kitchen_sink/lawn_mower.py +++ b/homeassistant/components/kitchen_sink/lawn_mower.py @@ -57,7 +57,8 @@ async def async_setup_platform( LawnMowerActivity.DOCKED, LawnMowerEntityFeature.DOCK | LawnMowerEntityFeature.PAUSE - | LawnMowerEntityFeature.START_MOWING, + | LawnMowerEntityFeature.START_MOWING + | LawnMowerEntityFeature.STOP, ), DemoLawnMower( "kitchen_sink_mower_006", @@ -67,6 +68,12 @@ async def async_setup_platform( | LawnMowerEntityFeature.PAUSE | LawnMowerEntityFeature.START_MOWING, ), + DemoLawnMower( + "kitchen_sink_mower_007", + "Mower can stop", + LawnMowerActivity.MOWING, + LawnMowerEntityFeature.STOP | LawnMowerEntityFeature.START_MOWING, + ), ] ) @@ -113,3 +120,9 @@ class DemoLawnMower(LawnMowerEntity): """Pause mower.""" self._attr_activity = LawnMowerActivity.PAUSED self.async_write_ha_state() + + @override + async def async_stop(self) -> None: + """Stop mower.""" + self._attr_activity = LawnMowerActivity.IDLE + self.async_write_ha_state() diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index a94ac0f1a128..fc6099166ed9 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -19,6 +19,7 @@ from .const import ( SERVICE_DOCK, SERVICE_PAUSE, SERVICE_START_MOWING, + SERVICE_STOP, LawnMowerActivity, LawnMowerEntityFeature, ) @@ -51,6 +52,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_DOCK, None, "async_dock", [LawnMowerEntityFeature.DOCK] ) + component.async_register_entity_service( + SERVICE_STOP, None, "async_stop", [LawnMowerEntityFeature.STOP] + ) return True @@ -123,3 +127,11 @@ class LawnMowerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): async def async_pause(self) -> None: """Pause the lawn mower.""" await self.hass.async_add_executor_job(self.pause) + + def stop(self) -> None: + """Stop the lawn mower.""" + raise NotImplementedError + + async def async_stop(self) -> None: + """Stop the lawn mower.""" + await self.hass.async_add_executor_job(self.stop) diff --git a/homeassistant/components/lawn_mower/condition.py b/homeassistant/components/lawn_mower/condition.py index 0e0589655f33..a99025a3045d 100644 --- a/homeassistant/components/lawn_mower/condition.py +++ b/homeassistant/components/lawn_mower/condition.py @@ -10,6 +10,7 @@ CONDITIONS: dict[str, type[Condition]] = { "is_encountering_an_error": make_entity_state_condition( DOMAIN, LawnMowerActivity.ERROR ), + "is_idle": make_entity_state_condition(DOMAIN, LawnMowerActivity.IDLE), "is_mowing": make_entity_state_condition(DOMAIN, LawnMowerActivity.MOWING), "is_paused": make_entity_state_condition(DOMAIN, LawnMowerActivity.PAUSED), "is_returning": make_entity_state_condition(DOMAIN, LawnMowerActivity.RETURNING), diff --git a/homeassistant/components/lawn_mower/conditions.yaml b/homeassistant/components/lawn_mower/conditions.yaml index 5fb1de71345f..068745a5fb4d 100644 --- a/homeassistant/components/lawn_mower/conditions.yaml +++ b/homeassistant/components/lawn_mower/conditions.yaml @@ -17,6 +17,7 @@ is_docked: *condition_common is_encountering_an_error: *condition_common +is_idle: *condition_common is_mowing: *condition_common is_paused: *condition_common is_returning: *condition_common diff --git a/homeassistant/components/lawn_mower/const.py b/homeassistant/components/lawn_mower/const.py index 16a99c5cab5b..125675c00632 100644 --- a/homeassistant/components/lawn_mower/const.py +++ b/homeassistant/components/lawn_mower/const.py @@ -22,6 +22,9 @@ class LawnMowerActivity(StrEnum): RETURNING = "returning" """Device is returning.""" + IDLE = "idle" + """Device is stopped, but neither docked nor paused.""" + class LawnMowerEntityFeature(IntFlag): """Supported features of the lawn mower entity.""" @@ -29,6 +32,7 @@ class LawnMowerEntityFeature(IntFlag): START_MOWING = 1 PAUSE = 2 DOCK = 4 + STOP = 8 DOMAIN: Final = "lawn_mower" @@ -36,3 +40,4 @@ DOMAIN: Final = "lawn_mower" SERVICE_START_MOWING = "start_mowing" SERVICE_PAUSE = "pause" SERVICE_DOCK = "dock" +SERVICE_STOP = "stop" diff --git a/homeassistant/components/lawn_mower/icons.json b/homeassistant/components/lawn_mower/icons.json index 3fccbbebca59..1c62eadedee2 100644 --- a/homeassistant/components/lawn_mower/icons.json +++ b/homeassistant/components/lawn_mower/icons.json @@ -6,6 +6,9 @@ "is_encountering_an_error": { "condition": "mdi:alert-circle-outline" }, + "is_idle": { + "condition": "mdi:stop" + }, "is_mowing": { "condition": "mdi:play" }, @@ -30,9 +33,15 @@ }, "start_mowing": { "service": "mdi:play" + }, + "stop": { + "service": "mdi:stop" } }, "triggers": { + "became_idle": { + "trigger": "mdi:stop" + }, "errored": { "trigger": "mdi:alert-circle-outline" }, diff --git a/homeassistant/components/lawn_mower/services.yaml b/homeassistant/components/lawn_mower/services.yaml index 8c9a2f1adcc7..634c356a4d1d 100644 --- a/homeassistant/components/lawn_mower/services.yaml +++ b/homeassistant/components/lawn_mower/services.yaml @@ -20,3 +20,10 @@ pause: domain: lawn_mower supported_features: - lawn_mower.LawnMowerEntityFeature.PAUSE + +stop: + target: + entity: + domain: lawn_mower + supported_features: + - lawn_mower.LawnMowerEntityFeature.STOP diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json index b46b636ef865..5e76816329e2 100644 --- a/homeassistant/components/lawn_mower/strings.json +++ b/homeassistant/components/lawn_mower/strings.json @@ -30,6 +30,18 @@ }, "name": "Lawn mower is encountering an error" }, + "is_idle": { + "description": "Tests if one or more lawn mowers are idle.", + "fields": { + "behavior": { + "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" + } + }, + "name": "Lawn mower is idle" + }, "is_mowing": { "description": "Tests if one or more lawn mowers are mowing.", "fields": { @@ -73,6 +85,7 @@ "state": { "docked": "Docked", "error": "[%key:common::state::error%]", + "idle": "[%key:common::state::idle%]", "mowing": "Mowing", "paused": "[%key:common::state::paused%]", "returning": "Returning" @@ -91,10 +104,26 @@ "start_mowing": { "description": "Starts a lawn mower's mowing task.", "name": "Start lawn mower" + }, + "stop": { + "description": "Stops a lawn mower's current task.", + "name": "Stop lawn mower" } }, "title": "Lawn mower", "triggers": { + "became_idle": { + "description": "Triggers when one or more lawn mowers become idle.", + "fields": { + "behavior": { + "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::trigger_for_name%]" + } + }, + "name": "Lawn mower became idle" + }, "errored": { "description": "Triggers when one or more lawn mowers encounter an error.", "fields": { diff --git a/homeassistant/components/lawn_mower/trigger.py b/homeassistant/components/lawn_mower/trigger.py index 7612c9c41209..e414038655fb 100644 --- a/homeassistant/components/lawn_mower/trigger.py +++ b/homeassistant/components/lawn_mower/trigger.py @@ -17,6 +17,7 @@ TRIGGERS: dict[str, type[Trigger]] = { "started_returning": make_entity_target_state_trigger( DOMAIN, LawnMowerActivity.RETURNING ), + "became_idle": make_entity_target_state_trigger(DOMAIN, LawnMowerActivity.IDLE), } diff --git a/homeassistant/components/lawn_mower/triggers.yaml b/homeassistant/components/lawn_mower/triggers.yaml index 8cd5d878be6f..f83ce7266393 100644 --- a/homeassistant/components/lawn_mower/triggers.yaml +++ b/homeassistant/components/lawn_mower/triggers.yaml @@ -20,3 +20,4 @@ errored: *trigger_common paused_mowing: *trigger_common started_mowing: *trigger_common started_returning: *trigger_common +became_idle: *trigger_common diff --git a/tests/components/kitchen_sink/snapshots/test_lawn_mower.ambr b/tests/components/kitchen_sink/snapshots/test_lawn_mower.ambr index 4b3165d606aa..1a4f73559863 100644 --- a/tests/components/kitchen_sink/snapshots/test_lawn_mower.ambr +++ b/tests/components/kitchen_sink/snapshots/test_lawn_mower.ambr @@ -4,7 +4,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Mower can do all', - : , + : , }), 'context': , 'entity_id': 'lawn_mower.mower_can_do_all', @@ -61,6 +61,18 @@ 'last_updated': , 'state': 'returning', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mower can stop', + : , + }), + 'context': , + 'entity_id': 'lawn_mower.mower_can_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'mowing', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Mower is paused', diff --git a/tests/components/kitchen_sink/test_lawn_mower.py b/tests/components/kitchen_sink/test_lawn_mower.py index 5bd4fc834f8e..f98621f3ff1b 100644 --- a/tests/components/kitchen_sink/test_lawn_mower.py +++ b/tests/components/kitchen_sink/test_lawn_mower.py @@ -11,6 +11,7 @@ from homeassistant.components.lawn_mower import ( SERVICE_DOCK, SERVICE_PAUSE, SERVICE_START_MOWING, + SERVICE_STOP, LawnMowerActivity, ) from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform @@ -78,6 +79,12 @@ async def test_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: LawnMowerActivity.RETURNING, LawnMowerActivity.DOCKED, ), + ( + "lawn_mower.mower_can_stop", + SERVICE_STOP, + LawnMowerActivity.MOWING, + LawnMowerActivity.IDLE, + ), ], ) async def test_mower( @@ -109,6 +116,7 @@ async def test_mower( SERVICE_DOCK, SERVICE_START_MOWING, SERVICE_PAUSE, + SERVICE_STOP, ], ) async def test_service_calls_mocked(hass: HomeAssistant, service_call) -> None: diff --git a/tests/components/lawn_mower/test_condition.py b/tests/components/lawn_mower/test_condition.py index 3c26c39181e2..28c0e21ac322 100644 --- a/tests/components/lawn_mower/test_condition.py +++ b/tests/components/lawn_mower/test_condition.py @@ -32,6 +32,7 @@ async def target_lawn_mowers(hass: HomeAssistant) -> dict[str, list[str]]: _CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { "is_docked": TargetSupport.STANDARD, "is_encountering_an_error": TargetSupport.STANDARD, + "is_idle": TargetSupport.STANDARD, "is_mowing": TargetSupport.STANDARD, "is_paused": TargetSupport.STANDARD, "is_returning": TargetSupport.STANDARD, @@ -43,6 +44,7 @@ _CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { [ ("lawn_mower.is_docked", {}, True, True), ("lawn_mower.is_encountering_an_error", {}, True, True), + ("lawn_mower.is_idle", {}, True, True), ("lawn_mower.is_mowing", {}, True, True), ("lawn_mower.is_paused", {}, True, True), ("lawn_mower.is_returning", {}, True, True), @@ -87,6 +89,11 @@ def test_condition_target_support() -> None: target_states=[LawnMowerActivity.ERROR], other_states=other_states(LawnMowerActivity.ERROR), ), + *parametrize_condition_states_any( + condition="lawn_mower.is_idle", + target_states=[LawnMowerActivity.IDLE], + other_states=other_states(LawnMowerActivity.IDLE), + ), *parametrize_condition_states_any( condition="lawn_mower.is_mowing", target_states=[LawnMowerActivity.MOWING], @@ -144,6 +151,11 @@ async def test_lawn_mower_state_condition_behavior_any( target_states=[LawnMowerActivity.ERROR], other_states=other_states(LawnMowerActivity.ERROR), ), + *parametrize_condition_states_all( + condition="lawn_mower.is_idle", + target_states=[LawnMowerActivity.IDLE], + other_states=other_states(LawnMowerActivity.IDLE), + ), *parametrize_condition_states_all( condition="lawn_mower.is_mowing", target_states=[LawnMowerActivity.MOWING], diff --git a/tests/components/lawn_mower/test_init.py b/tests/components/lawn_mower/test_init.py index bf501cc11476..2a78408e886f 100644 --- a/tests/components/lawn_mower/test_init.py +++ b/tests/components/lawn_mower/test_init.py @@ -160,6 +160,17 @@ async def test_sync_pause(hass: HomeAssistant) -> None: assert lawn_mower.pause.called +async def test_sync_stop(hass: HomeAssistant) -> None: + """Test if async stop calls sync stop.""" + lawn_mower = MockLawnMowerEntity() + lawn_mower.hass = hass + + lawn_mower.stop = MagicMock() + await lawn_mower.async_stop() + + assert lawn_mower.stop.called + + async def test_lawn_mower_default(hass: HomeAssistant) -> None: """Test lawn mower entity with defaults.""" lawn_mower = MockLawnMowerEntity() diff --git a/tests/components/lawn_mower/test_trigger.py b/tests/components/lawn_mower/test_trigger.py index 5f8b740b660a..1ca4f9d51785 100644 --- a/tests/components/lawn_mower/test_trigger.py +++ b/tests/components/lawn_mower/test_trigger.py @@ -35,6 +35,7 @@ _TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { "paused_mowing": TargetSupport.STANDARD, "started_mowing": TargetSupport.STANDARD, "started_returning": TargetSupport.STANDARD, + "became_idle": TargetSupport.STANDARD, } @@ -46,6 +47,7 @@ _TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { ("lawn_mower.paused_mowing", {}, True, True), ("lawn_mower.started_mowing", {}, True, True), ("lawn_mower.started_returning", {}, True, True), + ("lawn_mower.became_idle", {}, True, True), ], ) async def test_lawn_mower_trigger_options_validation( @@ -102,6 +104,11 @@ def test_trigger_target_support() -> None: target_states=[LawnMowerActivity.RETURNING], other_states=other_states(LawnMowerActivity.RETURNING), ), + *parametrize_trigger_states( + trigger="lawn_mower.became_idle", + target_states=[LawnMowerActivity.IDLE], + other_states=other_states(LawnMowerActivity.IDLE), + ), ], ) async def test_lawn_mower_state_trigger_behavior_each( @@ -159,6 +166,11 @@ async def test_lawn_mower_state_trigger_behavior_each( target_states=[LawnMowerActivity.RETURNING], other_states=other_states(LawnMowerActivity.RETURNING), ), + *parametrize_trigger_states( + trigger="lawn_mower.became_idle", + target_states=[LawnMowerActivity.IDLE], + other_states=other_states(LawnMowerActivity.IDLE), + ), ], ) async def test_lawn_mower_state_trigger_behavior_first( @@ -216,6 +228,11 @@ async def test_lawn_mower_state_trigger_behavior_first( target_states=[LawnMowerActivity.RETURNING], other_states=other_states(LawnMowerActivity.RETURNING), ), + *parametrize_trigger_states( + trigger="lawn_mower.became_idle", + target_states=[LawnMowerActivity.IDLE], + other_states=other_states(LawnMowerActivity.IDLE), + ), ], ) async def test_lawn_mower_state_trigger_behavior_all( diff --git a/tests/components/mqtt/test_lawn_mower.py b/tests/components/mqtt/test_lawn_mower.py index 534ade49f51f..4d9601716d3b 100644 --- a/tests/components/mqtt/test_lawn_mower.py +++ b/tests/components/mqtt/test_lawn_mower.py @@ -741,8 +741,8 @@ async def test_mqtt_payload_not_a_valid_activity_warning( assert ( "Invalid activity for lawn_mower.test_lawn_mower: 'painting' " - "(valid activities: ['error', 'paused', 'mowing', 'docked', 'returning'])" - in caplog.text + "(valid activities: ['error', 'paused', 'mowing', 'docked', 'returning', " + "'idle'])" in caplog.text )