diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index da77fc1ccb6..2fe016351d6 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -358,7 +358,7 @@ class DefaultAgent(ConversationEntity): except intent.MatchFailedError as match_error: # Intent was valid, but no entities matched the constraints. error_response_type, error_response_args = _get_match_error_response( - match_error + self.hass, match_error ) return _make_error_result( language, @@ -1037,6 +1037,7 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str def _get_match_error_response( + hass: core.HomeAssistant, match_error: intent.MatchFailedError, ) -> tuple[ErrorKey, dict[str, Any]]: """Return key and template arguments for error when target matching fails.""" @@ -1103,6 +1104,23 @@ def _get_match_error_response( # Invalid floor name return ErrorKey.NO_FLOOR, {"floor": result.no_match_name} + if reason == intent.MatchFailedReason.FEATURE: + # Feature not supported by entity + return ErrorKey.FEATURE_NOT_SUPPORTED, {} + + if reason == intent.MatchFailedReason.STATE: + # Entity is not in correct state + assert match_error.constraints.states + state = next(iter(match_error.constraints.states)) + if match_error.constraints.domains: + # Translate if domain is available + domain = next(iter(match_error.constraints.domains)) + state = translation.async_translate_state( + hass, state, domain, None, None, None + ) + + return ErrorKey.ENTITY_WRONG_STATE, {"state": state} + # Default error return ErrorKey.NO_INTENT, {} diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index b42a4c5004f..d69a65b9c6e 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==1.7.1", "home-assistant-intents==2024.4.24"] + "requirements": ["hassil==1.7.1", "home-assistant-intents==2024.5.28"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 113a4b551b2..0416b3ae4cf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ hass-nabucasa==0.81.0 hassil==1.7.1 home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240501.1 -home-assistant-intents==2024.4.24 +home-assistant-intents==2024.5.28 httpx==0.27.0 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 584a73d73ef..b2a0f0619e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1090,7 +1090,7 @@ holidays==0.49 home-assistant-frontend==20240501.1 # homeassistant.components.conversation -home-assistant-intents==2024.4.24 +home-assistant-intents==2024.5.28 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c84250f985..ac1ec6795e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -892,7 +892,7 @@ holidays==0.49 home-assistant-frontend==20240501.1 # homeassistant.components.conversation -home-assistant-intents==2024.4.24 +home-assistant-intents==2024.5.28 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 648a7d572ef..659ee8794b8 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -6,13 +6,18 @@ from unittest.mock import AsyncMock, patch from hassil.recognize import Intent, IntentData, MatchEntity, RecognizeResult import pytest -from homeassistant.components import conversation, cover +from homeassistant.components import conversation, cover, media_player from homeassistant.components.conversation import default_agent from homeassistant.components.homeassistant.exposed_entities import ( async_get_assistant_settings, ) +from homeassistant.components.intent import ( + TimerEventType, + TimerInfo, + async_register_timer_handler, +) from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, STATE_CLOSED -from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant +from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant, callback from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -792,6 +797,141 @@ async def test_error_duplicate_names_in_area( ) +async def test_error_wrong_state(hass: HomeAssistant, init_components) -> None: + """Test error message when no entities are in the correct state.""" + assert await async_setup_component(hass, media_player.DOMAIN, {}) + + hass.states.async_set( + "media_player.test_player", + media_player.STATE_IDLE, + {ATTR_FRIENDLY_NAME: "test player"}, + ) + expose_entity(hass, "media_player.test_player", True) + + result = await conversation.async_converse( + hass, "pause test player", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert result.response.speech["plain"]["speech"] == "Sorry, no device is playing" + + +async def test_error_feature_not_supported( + hass: HomeAssistant, init_components +) -> None: + """Test error message when no devices support a required feature.""" + assert await async_setup_component(hass, media_player.DOMAIN, {}) + + hass.states.async_set( + "media_player.test_player", + media_player.STATE_PLAYING, + {ATTR_FRIENDLY_NAME: "test player"}, + # missing VOLUME_SET feature + ) + expose_entity(hass, "media_player.test_player", True) + + result = await conversation.async_converse( + hass, "set test player volume to 100%", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, no device supports the required features" + ) + + +async def test_error_no_timer_support(hass: HomeAssistant, init_components) -> None: + """Test error message when a device does not support timers (no handler is registered).""" + device_id = "test_device" + + # No timer handler is registered for the device + result = await conversation.async_converse( + hass, "pause timer", None, Context(), None, device_id=device_id + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.FAILED_TO_HANDLE + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, timers are not supported on this device" + ) + + +async def test_error_timer_not_found(hass: HomeAssistant, init_components) -> None: + """Test error message when a timer cannot be matched.""" + device_id = "test_device" + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + pass + + # Register a handler so the device "supports" timers + async_register_timer_handler(hass, device_id, handle_timer) + + result = await conversation.async_converse( + hass, "pause timer", None, Context(), None, device_id=device_id + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.FAILED_TO_HANDLE + assert ( + result.response.speech["plain"]["speech"] == "Sorry, I couldn't find that timer" + ) + + +async def test_error_multiple_timers_matched( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test error message when an intent would target multiple timers.""" + area_kitchen = area_registry.async_create("kitchen") + + # Starting a timer requires a device in an area + entry = MockConfigEntry() + entry.add_to_hass(hass) + device_kitchen = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "device-kitchen")}, + ) + device_registry.async_update_device(device_kitchen.id, area_id=area_kitchen.id) + device_id = device_kitchen.id + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + pass + + # Register a handler so the device "supports" timers + async_register_timer_handler(hass, device_id, handle_timer) + + # Create two identical timers from the same device + result = await conversation.async_converse( + hass, "set a timer for 5 minutes", None, Context(), None, device_id=device_id + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + result = await conversation.async_converse( + hass, "set a timer for 5 minutes", None, Context(), None, device_id=device_id + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + # Cannot target multiple timers + result = await conversation.async_converse( + hass, "cancel timer", None, Context(), None, device_id=device_id + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.FAILED_TO_HANDLE + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I am unable to target multiple timers" + ) + + async def test_no_states_matched_default_error( hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry ) -> None: diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index 16b0ccf3107..f5050f4483e 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -234,7 +234,7 @@ async def test_media_player_intents( response = result.response assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.speech["plain"]["speech"] == "Unpaused" + assert response.speech["plain"]["speech"] == "Resumed" assert len(calls) == 1 call = calls[0] assert call.data == {"entity_id": entity_id}