From c9351a022ea28546afface55f2bc7d9d0d8de7d7 Mon Sep 17 00:00:00 2001 From: Ezra Freedman <38084742+ezra-freedman@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:23:49 -0500 Subject: [PATCH] Add HassStopMoving intent for covers and valves (#155267) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- homeassistant/components/intent/__init__.py | 28 +++++++ homeassistant/helpers/intent.py | 1 + tests/components/intent/test_init.py | 81 ++++++++++++++++++++- tests/helpers/test_llm.py | 1 + 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 17ec8602d98..56b8d7842ba 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -22,6 +22,7 @@ from homeassistant.components.cover import ( SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, CoverDeviceClass, ) from homeassistant.components.http.data_validator import RequestDataValidator @@ -38,6 +39,7 @@ from homeassistant.components.valve import ( SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE, SERVICE_SET_VALVE_POSITION, + SERVICE_STOP_VALVE, ValveDeviceClass, ) from homeassistant.const import ( @@ -143,6 +145,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: NevermindIntentHandler(), ) intent.async_register(hass, SetPositionIntentHandler()) + intent.async_register(hass, StopMovingIntentHandler()) intent.async_register(hass, StartTimerIntentHandler()) intent.async_register(hass, CancelTimerIntentHandler()) intent.async_register(hass, CancelAllTimersIntentHandler()) @@ -433,6 +436,31 @@ class SetPositionIntentHandler(intent.DynamicServiceIntentHandler): raise intent.IntentHandleError(f"Domain not supported: {state.domain}") +class StopMovingIntentHandler(intent.DynamicServiceIntentHandler): + """Intent handler for stopping covers and valves.""" + + def __init__(self) -> None: + """Create stop moving handler.""" + super().__init__( + intent.INTENT_STOP_MOVING, + description="Stops a moving device or entity", + platforms={COVER_DOMAIN, VALVE_DOMAIN}, + device_classes={CoverDeviceClass, ValveDeviceClass}, + ) + + def get_domain_and_service( + self, intent_obj: intent.Intent, state: State + ) -> tuple[str, str]: + """Get the domain and service name to call.""" + if state.domain == COVER_DOMAIN: + return (COVER_DOMAIN, SERVICE_STOP_COVER) + + if state.domain == VALVE_DOMAIN: + return (VALVE_DOMAIN, SERVICE_STOP_VALVE) + + raise intent.IntentHandleError(f"Domain not supported: {state.domain}") + + class GetCurrentDateIntentHandler(intent.IntentHandler): """Gets the current date.""" diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 5b21c12d755..011a81522b1 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -48,6 +48,7 @@ INTENT_TOGGLE = "HassToggle" INTENT_GET_STATE = "HassGetState" INTENT_NEVERMIND = "HassNevermind" INTENT_SET_POSITION = "HassSetPosition" +INTENT_STOP_MOVING = "HassStopMoving" INTENT_START_TIMER = "HassStartTimer" INTENT_CANCEL_TIMER = "HassCancelTimer" INTENT_CANCEL_ALL_TIMERS = "HassCancelAllTimers" diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 1993ebe46e4..b37e7e838f7 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -1,11 +1,25 @@ """Tests for Intent component.""" +from typing import Any + import pytest from homeassistant.components.button import SERVICE_PRESS -from homeassistant.components.cover import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, + CoverState, +) from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK -from homeassistant.components.valve import SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_STOP_VALVE, + ValveState, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -594,3 +608,66 @@ async def test_intents_respond_intent(hass: HomeAssistant) -> None: hass, "test", intent.INTENT_RESPOND, {"response": {"value": "Hello World"}} ) assert response.speech["plain"]["speech"] == "Hello World" + + +async def test_stop_moving_valve(hass: HomeAssistant) -> None: + """Test HassStopMoving intent for valves.""" + assert await async_setup_component(hass, "intent", {}) + + entity_id = f"{VALVE_DOMAIN}.test_valve" + hass.states.async_set(entity_id, ValveState.OPEN) + calls = async_mock_service(hass, VALVE_DOMAIN, SERVICE_STOP_VALVE) + + response = await intent.async_handle( + hass, "test", intent.INTENT_STOP_MOVING, {"name": {"value": "test valve"}} + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == VALVE_DOMAIN + assert call.service == SERVICE_STOP_VALVE + assert call.data == {"entity_id": entity_id} + + +@pytest.mark.parametrize( + ("slots"), + [ + ({"name": {"value": "test cover"}}), + ({"device_class": {"value": "shade"}}), + ], +) +async def test_stop_moving_cover(hass: HomeAssistant, slots: dict[str, Any]) -> None: + """Test HassStopMoving intent for covers.""" + assert await async_setup_component(hass, "intent", {}) + + entity_id = f"{COVER_DOMAIN}.test_cover" + hass.states.async_set( + entity_id, CoverState.OPEN, attributes={"device_class": "shade"} + ) + calls = async_mock_service(hass, COVER_DOMAIN, SERVICE_STOP_COVER) + + response = await intent.async_handle(hass, "test", intent.INTENT_STOP_MOVING, slots) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == COVER_DOMAIN + assert call.service == SERVICE_STOP_COVER + assert call.data == {"entity_id": entity_id} + + +async def test_stop_moving_intent_unsupported_domain(hass: HomeAssistant) -> None: + """Test that HassStopMoving intent fails with unsupported domain.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + # Can't stop lights + hass.states.async_set("light.test_light", "on") + + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, "test", intent.INTENT_STOP_MOVING, {"name": {"value": "test light"}} + ) diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 7e237cc495e..f57e7ae1eba 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -369,6 +369,7 @@ async def test_assist_api_tools( "HassTurnOn", "HassTurnOff", "HassSetPosition", + "HassStopMoving", "HassStartTimer", "HassCancelTimer", "HassCancelAllTimers",