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>
This commit is contained in:
Paul Bottein
2026-09-11 11:58:12 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 86584eab66
commit f7a35f97c8
16 changed files with 143 additions and 4 deletions
@@ -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()
@@ -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)
@@ -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),
@@ -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
@@ -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"
@@ -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"
},
@@ -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
@@ -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": {
@@ -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),
}
@@ -20,3 +20,4 @@ errored: *trigger_common
paused_mowing: *trigger_common
started_mowing: *trigger_common
started_returning: *trigger_common
became_idle: *trigger_common
@@ -4,7 +4,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Mower can do all',
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <LawnMowerEntityFeature: 7>,
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <LawnMowerEntityFeature: 15>,
}),
'context': <ANY>,
'entity_id': 'lawn_mower.mower_can_do_all',
@@ -61,6 +61,18 @@
'last_updated': <ANY>,
'state': 'returning',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Mower can stop',
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <LawnMowerEntityFeature: 9>,
}),
'context': <ANY>,
'entity_id': 'lawn_mower.mower_can_stop',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'mowing',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Mower is paused',
@@ -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:
@@ -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],
+11
View File
@@ -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()
@@ -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(
+2 -2
View File
@@ -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
)