From 9e443bcd8be688f56948315dacf86a36d6ac34bb Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 11 Sep 2026 13:11:21 +0200 Subject: [PATCH] Add stop action to MQTT lawn mower (#181192) --- .../components/mqtt/abbreviations.py | 2 + homeassistant/components/mqtt/lawn_mower.py | 16 +++++ tests/components/mqtt/test_lawn_mower.py | 71 +++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index b0abaebdce34..a2249a6b79f5 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -215,6 +215,8 @@ ABBREVIATIONS = { "stat_tpl": "state_template", "stat_val_tpl": "state_value_template", "step": "step", + "stop_cmd_t": "stop_command_topic", + "stop_cmd_tpl": "stop_command_template", "strt_mw_cmd_t": "start_mowing_command_topic", "strt_mw_cmd_tpl": "start_mowing_command_template", "stype": "subtype", diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 5d42f120502a..5ec5857bab07 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -48,6 +48,8 @@ CONF_PAUSE_COMMAND_TOPIC = "pause_command_topic" CONF_PAUSE_COMMAND_TEMPLATE = "pause_command_template" CONF_START_MOWING_COMMAND_TOPIC = "start_mowing_command_topic" CONF_START_MOWING_COMMAND_TEMPLATE = "start_mowing_command_template" +CONF_STOP_COMMAND_TOPIC = "stop_command_topic" +CONF_STOP_COMMAND_TEMPLATE = "stop_command_template" DEFAULT_NAME = "MQTT Lawn Mower" @@ -56,6 +58,7 @@ MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() FEATURE_DOCK = "dock" FEATURE_PAUSE = "pause" FEATURE_START_MOWING = "start_mowing" +FEATURE_STOP = "stop" PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( { @@ -70,6 +73,8 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_START_MOWING_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_START_MOWING_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_STOP_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_STOP_COMMAND_TOPIC): valid_publish_topic, }, ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) @@ -130,6 +135,9 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): CONF_START_MOWING_COMMAND_TOPIC ] supported_features |= LawnMowerEntityFeature.START_MOWING + if CONF_STOP_COMMAND_TOPIC in config: + self._command_topics[FEATURE_STOP] = config[CONF_STOP_COMMAND_TOPIC] + supported_features |= LawnMowerEntityFeature.STOP self._attr_supported_features = supported_features self._command_templates = {} self._command_templates[FEATURE_DOCK] = MqttCommandTemplate( @@ -141,6 +149,9 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): self._command_templates[FEATURE_START_MOWING] = MqttCommandTemplate( config.get(CONF_START_MOWING_COMMAND_TEMPLATE), entity=self ).async_render + self._command_templates[FEATURE_STOP] = MqttCommandTemplate( + config.get(CONF_STOP_COMMAND_TEMPLATE), entity=self + ).async_render @callback def _message_received(self, msg: ReceiveMessage) -> None: @@ -212,3 +223,8 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): async def async_pause(self) -> None: """Pause the lawn mower.""" await self._async_operate("pause", LawnMowerActivity.PAUSED) + + @override + async def async_stop(self) -> None: + """Stop the lawn mower.""" + await self._async_operate("stop", LawnMowerActivity.IDLE) diff --git a/tests/components/mqtt/test_lawn_mower.py b/tests/components/mqtt/test_lawn_mower.py index 4d9601716d3b..ebe9b15d3f29 100644 --- a/tests/components/mqtt/test_lawn_mower.py +++ b/tests/components/mqtt/test_lawn_mower.py @@ -13,6 +13,7 @@ from homeassistant.components.lawn_mower import ( SERVICE_DOCK, SERVICE_PAUSE, SERVICE_START_MOWING, + SERVICE_STOP, LawnMowerEntityFeature, ) from homeassistant.components.mqtt.const import DOMAIN @@ -61,6 +62,7 @@ DEFAULT_FEATURES = ( LawnMowerEntityFeature.START_MOWING | LawnMowerEntityFeature.PAUSE | LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.STOP ) DEFAULT_CONFIG = { @@ -70,6 +72,7 @@ DEFAULT_CONFIG = { "dock_command_topic": "dock-test-topic", "pause_command_topic": "pause-test-topic", "start_mowing_command_topic": "start_mowing-test-topic", + "stop_command_topic": "stop-test-topic", } } } @@ -111,6 +114,13 @@ async def test_run_lawn_mower_setup_and_state_updates( state = hass.states.get("lawn_mower.test_lawn_mower") assert state.state == "returning" + async_fire_mqtt_message(hass, "test/lawn_mower_stat", "idle") + + await hass.async_block_till_done() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "idle" + async_fire_mqtt_message(hass, "test/lawn_mower_stat", "docked") await hass.async_block_till_done() @@ -157,6 +167,17 @@ async def test_run_lawn_mower_setup_and_state_updates( }, LawnMowerEntityFeature.START_MOWING | LawnMowerEntityFeature.DOCK, ), + ( + { + DOMAIN: { + lawn_mower.DOMAIN: { + "stop_command_topic": "stop-test-topic", + "name": "test", + } + } + }, + LawnMowerEntityFeature.STOP, + ), ], ) async def test_supported_features( @@ -285,6 +306,20 @@ async def test_run_lawn_mower_service_optimistic( state = hass.states.get("lawn_mower.test") assert state.state == "docked" + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_STOP, + {ATTR_ENTITY_ID: "lawn_mower.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "stop-test-topic", "stop", 0, False, message_expiry_interval=None + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test") + assert state.state == "idle" + @pytest.mark.parametrize( "hass_config", @@ -325,6 +360,8 @@ async def test_restore_lawn_mower_from_invalid_state( "pause_command_template": '{"action": "{{ value }}"}', "start_mowing_command_topic": "test/lawn_mower_start_mowing_cmd", "start_mowing_command_template": '{"action": "{{ value }}"}', + "stop_command_topic": "test/lawn_mower_stop_cmd", + "stop_command_template": '{"action": "{{ value }}"}', } } } @@ -397,6 +434,24 @@ async def test_run_lawn_mower_service_optimistic_with_command_templates( state = hass.states.get("lawn_mower.test_lawn_mower") assert state.state == "docked" + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_STOP, + {ATTR_ENTITY_ID: "lawn_mower.test_lawn_mower"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "test/lawn_mower_stop_cmd", + '{"action": "stop"}', + 0, + False, + message_expiry_interval=None, + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "idle" + @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( @@ -675,6 +730,13 @@ async def test_entity_id_update_discovery_update( "test/lawn_mower_stat", "dock-test-topic", ), + ( + SERVICE_STOP, + "stop", + "idle", + "test/lawn_mower_stat", + "stop-test-topic", + ), ], ) async def test_entity_debug_info_message( @@ -694,6 +756,7 @@ async def test_entity_debug_info_message( "dock_command_topic": "dock-test-topic", "pause_command_topic": "pause-test-topic", "start_mowing_command_topic": "start_mowing-test-topic", + "stop_command_topic": "stop-test-topic", "name": "test", } } @@ -770,6 +833,13 @@ async def test_mqtt_payload_not_a_valid_activity_warning( "dock", "dock_command_template", ), + ( + SERVICE_STOP, + "stop_command_topic", + {}, + "stop", + "stop_command_template", + ), ], ) async def test_publishing_with_custom_encoding( @@ -816,6 +886,7 @@ async def test_reloadable( ("activity_state_topic", "docked", None, "docked"), ("activity_state_topic", "returning", None, "returning"), ("activity_state_topic", "mowing", None, "mowing"), + ("activity_state_topic", "idle", None, "idle"), ], ) async def test_encoding_subscribable_topics(