Add missing features to Wyoming conversation agent (#164278)

This commit is contained in:
Michael Hansen
2026-03-05 15:56:21 +01:00
committed by GitHub
parent 5907356309
commit fc723e1a42
3 changed files with 96 additions and 8 deletions
@@ -1,6 +1,7 @@
"""Support for Wyoming intent recognition services."""
import logging
from typing import Literal
from wyoming.asr import Transcript
from wyoming.client import AsyncTcpClient
@@ -10,6 +11,7 @@ from wyoming.intent import Intent, NotRecognized
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -89,8 +91,11 @@ class WyomingConversationEntity(
self._attr_unique_id = f"{config_entry.entry_id}-conversation"
@property
def supported_languages(self) -> list[str]:
def supported_languages(self) -> list[str] | Literal["*"]:
"""Return a list of supported languages."""
if not self._supported_languages:
return MATCH_ALL
return self._supported_languages
async def async_process(
@@ -100,11 +105,17 @@ class WyomingConversationEntity(
conversation_id = user_input.conversation_id or ulid_util.ulid_now()
intent_response = intent.IntentResponse(language=user_input.language)
context = {"conversation_id": conversation_id}
if user_input.satellite_id:
context["satellite_id"] = user_input.satellite_id
try:
async with AsyncTcpClient(self.service.host, self.service.port) as client:
await client.write_event(
Transcript(
user_input.text, context={"conversation_id": conversation_id}
user_input.text,
context=context,
language=user_input.language,
).event()
)
@@ -138,6 +149,8 @@ class WyomingConversationEntity(
intent_slots,
text_input=user_input.text,
language=user_input.language,
satellite_id=user_input.satellite_id,
device_id=user_input.device_id,
)
if (not intent_response.speech) and recognized_intent.text:
+6 -1
View File
@@ -3,6 +3,7 @@
import asyncio
from unittest.mock import patch
from wyoming.asr import Transcript
from wyoming.event import Event
from wyoming.info import (
AsrModel,
@@ -172,13 +173,14 @@ EMPTY_INFO = Info()
class MockAsyncTcpClient:
"""Mock AsyncTcpClient."""
def __init__(self, responses: list[Event]) -> None:
def __init__(self, responses: list[Event | None]) -> None:
"""Initialize."""
self.host: str | None = None
self.port: int | None = None
self.written: list[Event] = []
self.responses = responses
self.is_connected: bool | None = None
self.transcript: Transcript | None = None
async def connect(self) -> None:
"""Connect."""
@@ -192,6 +194,9 @@ class MockAsyncTcpClient:
"""Send."""
self.written.append(event)
if Transcript.is_type(event.type):
self.transcript = Transcript.from_event(event)
async def read_event(self) -> Event | None:
"""Receive."""
await asyncio.sleep(0) # force context switch
+75 -5
View File
@@ -4,24 +4,29 @@ from __future__ import annotations
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from wyoming.asr import Transcript
from wyoming.handle import Handled, NotHandled
from wyoming.info import Info
from wyoming.intent import Entity, Intent, NotRecognized
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import MATCH_ALL
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import intent
from . import MockAsyncTcpClient
from . import HANDLE_INFO, INTENT_INFO, MockAsyncTcpClient
async def test_intent(hass: HomeAssistant, init_wyoming_intent: ConfigEntry) -> None:
"""Test when an intent is recognized."""
agent_id = "conversation.test_intent"
conversation_id = "conversation-1234"
satellite_id = "satellite-1234"
device_id = "device-1234"
test_intent = Intent(
name="TestIntent",
entities=[Entity(name="entity", value="value")],
@@ -36,13 +41,16 @@ async def test_intent(hass: HomeAssistant, init_wyoming_intent: ConfigEntry) ->
async def async_handle(self, intent_obj: intent.Intent):
"""Handle the intent."""
assert intent_obj.slots.get("entity", {}).get("value") == "value"
assert intent_obj.satellite_id == satellite_id
assert intent_obj.device_id == device_id
return intent_obj.create_response()
intent.async_register(hass, TestIntentHandler())
client = MockAsyncTcpClient([test_intent.event()])
with patch(
"homeassistant.components.wyoming.conversation.AsyncTcpClient",
MockAsyncTcpClient([test_intent.event()]),
client,
):
result = await conversation.async_converse(
hass=hass,
@@ -51,8 +59,18 @@ async def test_intent(hass: HomeAssistant, init_wyoming_intent: ConfigEntry) ->
context=Context(),
language=hass.config.language,
agent_id=agent_id,
satellite_id=satellite_id,
device_id=device_id,
)
# Ensure language and context are sent
assert client.transcript is not None
assert client.transcript.language == hass.config.language
assert client.transcript.context == {
"conversation_id": conversation_id,
"satellite_id": satellite_id,
}
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert result.response.speech, "No speech"
assert result.response.speech.get("plain", {}).get("speech") == "success"
@@ -123,12 +141,13 @@ async def test_not_recognized(
async def test_handle(hass: HomeAssistant, init_wyoming_handle: ConfigEntry) -> None:
"""Test when an intent is handled."""
agent_id = "conversation.test_handle"
conversation_id = "conversation-1234"
satellite_id = "satellite-1234"
client = MockAsyncTcpClient([Handled(text="success").event()])
with patch(
"homeassistant.components.wyoming.conversation.AsyncTcpClient",
MockAsyncTcpClient([Handled(text="success").event()]),
client,
):
result = await conversation.async_converse(
hass=hass,
@@ -137,8 +156,17 @@ async def test_handle(hass: HomeAssistant, init_wyoming_handle: ConfigEntry) ->
context=Context(),
language=hass.config.language,
agent_id=agent_id,
satellite_id=satellite_id,
)
# Ensure language and context are sent
assert client.transcript is not None
assert client.transcript.language == hass.config.language
assert client.transcript.context == {
"conversation_id": conversation_id,
"satellite_id": satellite_id,
}
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert result.response.speech, "No speech"
assert result.response.speech.get("plain", {}).get("speech") == "success"
@@ -222,3 +250,45 @@ async def test_oserror(
assert result.response.error_code == intent.IntentResponseErrorCode.UNKNOWN
assert result.response.speech, "No speech"
assert result.response.speech.get("plain", {}).get("speech") == snapshot
@pytest.mark.parametrize(
("config_entry_fixture", "info_obj", "info_kwargs", "agent_id"),
[
(
"intent_config_entry",
INTENT_INFO.intent[0].models[0],
{"intent": INTENT_INFO.intent},
"conversation.test_intent",
),
(
"handle_config_entry",
HANDLE_INFO.handle[0].models[0],
{"handle": HANDLE_INFO.handle},
"conversation.test_handle",
),
],
)
async def test_supported_languages_empty_means_all(
hass: HomeAssistant,
request: pytest.FixtureRequest,
config_entry_fixture: str,
info_obj,
info_kwargs: dict,
agent_id: str,
) -> None:
"""Test that an empty list of supported languages means the agent supports all languages."""
config_entry: ConfigEntry = request.getfixturevalue(config_entry_fixture)
with (
patch.object(info_obj, "languages", []),
patch(
"homeassistant.components.wyoming.data.load_wyoming_info",
return_value=Info(**info_kwargs),
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
agent = conversation.async_get_agent(hass, agent_id)
assert agent is not None
assert agent.supported_languages == MATCH_ALL