Compare commits

...

3 Commits

Author SHA1 Message Date
Michael Hansen
3d47bed74d Test media intent match errors 2026-03-03 14:44:16 -06:00
Michael Hansen
cd4db314cd Implement suggestions 2026-03-03 11:58:18 -06:00
Michael Hansen
5b2e0e2a4f Add missing parameters from handle API 2026-03-03 11:27:30 -06:00
4 changed files with 291 additions and 23 deletions

View File

@@ -627,13 +627,17 @@ class IntentHandleView(http.HomeAssistantView):
{
vol.Required("name"): cv.string,
vol.Optional("data"): vol.Schema({cv.string: object}),
vol.Optional("language"): cv.string,
vol.Optional("assistant"): vol.Any(cv.string, None),
vol.Optional("device_id"): vol.Any(cv.string, None),
vol.Optional("satellite_id"): vol.Any(cv.string, None),
}
)
)
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle intent with name/data."""
hass = request.app[http.KEY_HASS]
language = hass.config.language
language = data.get("language", hass.config.language)
try:
intent_name = data["name"]
@@ -641,14 +645,21 @@ class IntentHandleView(http.HomeAssistantView):
key: {"value": value} for key, value in data.get("data", {}).items()
}
intent_result = await intent.async_handle(
hass, DOMAIN, intent_name, slots, "", self.context(request)
hass,
DOMAIN,
intent_name,
slots,
"",
self.context(request),
language=language,
assistant=data.get("assistant"),
device_id=data.get("device_id"),
satellite_id=data.get("satellite_id"),
)
except (intent.IntentHandleError, intent.MatchFailedError) as err:
intent_result = intent.IntentResponse(language=language)
intent_result.async_set_speech(str(err))
if intent_result is None:
intent_result = intent.IntentResponse(language=language) # type: ignore[unreachable]
intent_result.async_set_speech("Sorry, I couldn't handle that")
intent_result.async_set_error(
intent.IntentResponseErrorCode.FAILED_TO_HANDLE, str(err)
)
return self.json(intent_result)

View File

@@ -2,6 +2,7 @@
import asyncio
from collections.abc import Iterable
import dataclasses
from dataclasses import dataclass, field
import logging
import time
@@ -481,7 +482,9 @@ class MediaSetVolumeRelativeHandler(intent.IntentHandler):
result=intent.MatchTargetsResult(
is_match=False, no_match_reason=intent.MatchFailedReason.STATE
),
constraints=match_constraints,
constraints=dataclasses.replace(
match_constraints, states=[MediaPlayerState.PLAYING]
),
preferences=match_preferences,
)

View File

@@ -4,6 +4,7 @@ from typing import Any
import pytest
from homeassistant.components import conversation
from homeassistant.components.button import SERVICE_PRESS
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
@@ -12,6 +13,7 @@ from homeassistant.components.cover import (
SERVICE_STOP_COVER,
CoverState,
)
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK
from homeassistant.components.valve import (
DOMAIN as VALVE_DOMAIN,
@@ -82,6 +84,70 @@ async def test_http_handle_intent(
}
},
"language": hass.config.language,
"response_type": intent.IntentResponseType.ACTION_DONE.value,
"data": {"targets": [], "success": [], "failed": []},
}
async def test_http_language_device_satellite_id(
hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser
) -> None:
"""Test handle intent with language, device id, and satellite id."""
device_id = "test-device-id"
satellite_id = "test-satellite-id"
language = "en-GB"
class TestIntentHandler(intent.IntentHandler):
"""Test Intent Handler."""
intent_type = "TestIntent"
async def async_handle(self, intent_obj: intent.Intent):
"""Handle the intent."""
assert intent_obj.context.user_id == hass_admin_user.id
assert intent_obj.device_id == device_id
assert intent_obj.satellite_id == satellite_id
assert intent_obj.language == language
response = intent_obj.create_response()
response.async_set_speech("Test response")
response.async_set_speech_slots({"slot1": "value 1", "slot2": 2})
return response
intent.async_register(hass, TestIntentHandler())
result = await async_setup_component(hass, "intent", {})
assert result
client = await hass_client()
resp = await client.post(
"/api/intent/handle",
json={
"name": "TestIntent",
"language": language,
"device_id": device_id,
"satellite_id": satellite_id,
},
)
assert resp.status == 200
data = await resp.json()
# Verify language, device id, and satellite id were passed through.
# Also check speech slots.
assert data == {
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Test response",
}
},
"speech_slots": {
"slot1": "value 1",
"slot2": 2,
},
"language": language,
"response_type": "action_done",
"data": {"targets": [], "success": [], "failed": []},
}
@@ -113,6 +179,60 @@ async def test_http_handle_intent_match_failure(
assert "DUPLICATE_NAME" in data["speech"]["plain"]["speech"]
async def test_http_assistant(
hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser
) -> None:
"""Test handle intent only targets exposed entities with 'assistant' set."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "intent", {})
hass.states.async_set(
"cover.garage_door_1", "closed", {ATTR_FRIENDLY_NAME: "Garage Door 1"}
)
async_mock_service(hass, "cover", SERVICE_OPEN_COVER)
client = await hass_client()
# Exposed
async_expose_entity(hass, conversation.DOMAIN, "cover.garage_door_1", True)
resp = await client.post(
"/api/intent/handle",
json={
"name": "HassTurnOn",
"data": {"name": "Garage Door 1"},
"assistant": conversation.DOMAIN,
},
)
assert resp.status == 200
data = await resp.json()
assert data["response_type"] == intent.IntentResponseType.ACTION_DONE.value
# Not exposed
async_expose_entity(hass, conversation.DOMAIN, "cover.garage_door_1", False)
resp = await client.post(
"/api/intent/handle",
json={
"name": "HassTurnOn",
"data": {"name": "Garage Door 1"},
"assistant": conversation.DOMAIN,
},
)
assert resp.status == 200
data = await resp.json()
assert data["response_type"] == intent.IntentResponseType.ERROR.value
assert data["data"]["code"] == intent.IntentResponseErrorCode.FAILED_TO_HANDLE.value
# No assistant (exposure is irrelevant)
resp = await client.post(
"/api/intent/handle",
json={"name": "HassTurnOn", "data": {"name": "Garage Door 1"}},
)
assert resp.status == 200
data = await resp.json()
assert data["response_type"] == intent.IntentResponseType.ACTION_DONE.value
async def test_cover_intents_loading(hass: HomeAssistant) -> None:
"""Test Cover Intents Loading."""
assert await async_setup_component(hass, "intent", {})

View File

@@ -78,13 +78,21 @@ async def test_pause_media_player_intent(hass: HomeAssistant) -> None:
# Test if not playing
hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes)
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
response = await intent.async_handle(
hass,
"test",
media_player_intent.INTENT_MEDIA_PAUSE,
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert match_failed_error.result.no_match_reason == intent.MatchFailedReason.STATE
assert match_failed_error.constraints.states
assert list(match_failed_error.constraints.states) == [MediaPlayerState.PLAYING]
# Test feature not supported
hass.states.async_set(
entity_id,
@@ -92,13 +100,20 @@ async def test_pause_media_player_intent(hass: HomeAssistant) -> None:
attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)},
)
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
response = await intent.async_handle(
hass,
"test",
media_player_intent.INTENT_MEDIA_PAUSE,
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert match_failed_error.result.no_match_reason == intent.MatchFailedReason.FEATURE
assert match_failed_error.constraints.features == MediaPlayerEntityFeature.PAUSE
async def test_unpause_media_player_intent(hass: HomeAssistant) -> None:
"""Test HassMediaUnpause intent for media players."""
@@ -151,13 +166,21 @@ async def test_next_media_player_intent(hass: HomeAssistant) -> None:
# Test if not playing
hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes)
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
response = await intent.async_handle(
hass,
"test",
media_player_intent.INTENT_MEDIA_NEXT,
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert match_failed_error.result.no_match_reason == intent.MatchFailedReason.STATE
assert match_failed_error.constraints.states
assert list(match_failed_error.constraints.states) == [MediaPlayerState.PLAYING]
# Test feature not supported
hass.states.async_set(
entity_id,
@@ -165,7 +188,7 @@ async def test_next_media_player_intent(hass: HomeAssistant) -> None:
attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)},
)
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
response = await intent.async_handle(
hass,
"test",
@@ -173,6 +196,15 @@ async def test_next_media_player_intent(hass: HomeAssistant) -> None:
{"name": {"value": "test media player"}},
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert match_failed_error.result.no_match_reason == intent.MatchFailedReason.FEATURE
assert (
match_failed_error.constraints.features == MediaPlayerEntityFeature.NEXT_TRACK
)
async def test_previous_media_player_intent(hass: HomeAssistant) -> None:
"""Test HassMediaPrevious intent for media players."""
@@ -202,13 +234,21 @@ async def test_previous_media_player_intent(hass: HomeAssistant) -> None:
# Test if not playing
hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes)
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
response = await intent.async_handle(
hass,
"test",
media_player_intent.INTENT_MEDIA_PREVIOUS,
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert match_failed_error.result.no_match_reason == intent.MatchFailedReason.STATE
assert match_failed_error.constraints.states
assert list(match_failed_error.constraints.states) == [MediaPlayerState.PLAYING]
# Test feature not supported
hass.states.async_set(
entity_id,
@@ -216,7 +256,7 @@ async def test_previous_media_player_intent(hass: HomeAssistant) -> None:
attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)},
)
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
response = await intent.async_handle(
hass,
"test",
@@ -224,6 +264,16 @@ async def test_previous_media_player_intent(hass: HomeAssistant) -> None:
{"name": {"value": "test media player"}},
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert match_failed_error.result.no_match_reason == intent.MatchFailedReason.FEATURE
assert (
match_failed_error.constraints.features
== MediaPlayerEntityFeature.PREVIOUS_TRACK
)
async def test_volume_media_player_intent(hass: HomeAssistant) -> None:
"""Test HassSetVolume intent for media players."""
@@ -257,7 +307,7 @@ async def test_volume_media_player_intent(hass: HomeAssistant) -> None:
attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)},
)
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
response = await intent.async_handle(
hass,
"test",
@@ -265,6 +315,15 @@ async def test_volume_media_player_intent(hass: HomeAssistant) -> None:
{"volume_level": {"value": 50}},
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert match_failed_error.result.no_match_reason == intent.MatchFailedReason.FEATURE
assert (
match_failed_error.constraints.features == MediaPlayerEntityFeature.VOLUME_SET
)
async def test_media_player_mute_intent(hass: HomeAssistant) -> None:
"""Test HassMediaPlayerMute intent for media players."""
@@ -298,7 +357,7 @@ async def test_media_player_mute_intent(hass: HomeAssistant) -> None:
attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)},
)
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
response = await intent.async_handle(
hass,
"test",
@@ -306,6 +365,15 @@ async def test_media_player_mute_intent(hass: HomeAssistant) -> None:
{},
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert match_failed_error.result.no_match_reason == intent.MatchFailedReason.FEATURE
assert (
match_failed_error.constraints.features == MediaPlayerEntityFeature.VOLUME_MUTE
)
async def test_media_player_unmute_intent(hass: HomeAssistant) -> None:
"""Test HassMediaPlayerMute intent for media players."""
@@ -339,7 +407,7 @@ async def test_media_player_unmute_intent(hass: HomeAssistant) -> None:
attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)},
)
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
response = await intent.async_handle(
hass,
"test",
@@ -347,6 +415,15 @@ async def test_media_player_unmute_intent(hass: HomeAssistant) -> None:
{},
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert match_failed_error.result.no_match_reason == intent.MatchFailedReason.FEATURE
assert (
match_failed_error.constraints.features == MediaPlayerEntityFeature.VOLUME_MUTE
)
async def test_multiple_media_players(
hass: HomeAssistant,
@@ -462,7 +539,7 @@ async def test_multiple_media_players(
# -----
# There are multiple TV's currently playing
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
response = await intent.async_handle(
hass,
"test",
@@ -470,6 +547,16 @@ async def test_multiple_media_players(
{"name": {"value": "TV"}},
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert (
match_failed_error.result.no_match_reason
== intent.MatchFailedReason.DUPLICATE_NAME
)
assert match_failed_error.result.no_match_name == "TV"
# Pause the upstairs TV
calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PAUSE)
response = await intent.async_handle(
@@ -826,7 +913,7 @@ async def test_search_and_play_media_player_intent(hass: HomeAssistant) -> None:
STATE_IDLE,
attributes={},
)
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
await intent.async_handle(
hass,
"test",
@@ -834,13 +921,23 @@ async def test_search_and_play_media_player_intent(hass: HomeAssistant) -> None:
{"search_query": {"value": "test query"}},
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert match_failed_error.result.no_match_reason == intent.MatchFailedReason.FEATURE
assert (
match_failed_error.constraints.features
== MediaPlayerEntityFeature.SEARCH_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA
)
# Test feature not supported (missing SEARCH_MEDIA)
hass.states.async_set(
entity_id,
STATE_IDLE,
attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.PLAY_MEDIA},
)
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
await intent.async_handle(
hass,
"test",
@@ -848,6 +945,16 @@ async def test_search_and_play_media_player_intent(hass: HomeAssistant) -> None:
{"search_query": {"value": "test query"}},
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert match_failed_error.result.no_match_reason == intent.MatchFailedReason.FEATURE
assert (
match_failed_error.constraints.features
== MediaPlayerEntityFeature.SEARCH_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA
)
# Test play media service errors
search_results.append(search_result_item)
hass.states.async_set(
@@ -862,7 +969,7 @@ async def test_search_and_play_media_player_intent(hass: HomeAssistant) -> None:
SERVICE_PLAY_MEDIA,
raise_exception=HomeAssistantError("Play failed"),
)
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
await intent.async_handle(
hass,
"test",
@@ -870,6 +977,16 @@ async def test_search_and_play_media_player_intent(hass: HomeAssistant) -> None:
{"search_query": {"value": "play error query"}},
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert match_failed_error.result.no_match_reason == intent.MatchFailedReason.FEATURE
assert (
match_failed_error.constraints.features
== MediaPlayerEntityFeature.SEARCH_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA
)
# Test search service error
hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes)
async_mock_service(
@@ -1105,7 +1222,7 @@ async def test_volume_relative_media_player_intent(
attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET},
)
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
await intent.async_handle(
hass,
"test",
@@ -1113,6 +1230,14 @@ async def test_volume_relative_media_player_intent(
{"volume_step": {"value": direction}},
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert match_failed_error.result.no_match_reason == intent.MatchFailedReason.STATE
assert match_failed_error.constraints.states
assert list(match_failed_error.constraints.states) == [MediaPlayerState.PLAYING]
# Test feature not supported
for entity_id in (idle_entity.entity_id, playing_entity.entity_id):
hass.states.async_set(
@@ -1121,10 +1246,19 @@ async def test_volume_relative_media_player_intent(
attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)},
)
with pytest.raises(intent.MatchFailedError):
with pytest.raises(intent.MatchFailedError) as error_wrapper:
await intent.async_handle(
hass,
"test",
media_player_intent.INTENT_SET_VOLUME_RELATIVE,
{"volume_step": {"value": direction}},
)
# Verify match failure reason and info
match_failed_error = error_wrapper.value
assert isinstance(match_failed_error, intent.MatchFailedError)
assert not match_failed_error.result.is_match
assert match_failed_error.result.no_match_reason == intent.MatchFailedReason.FEATURE
assert (
match_failed_error.constraints.features == MediaPlayerEntityFeature.VOLUME_SET
)