From b4f5811c3df7158701219186ac852e40c78e8664 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 12 Aug 2026 10:04:37 -0500 Subject: [PATCH] Handle Wyoming error events (#178696) Co-authored-by: Claude Opus 5 --- .../components/wyoming/conversation.py | 14 +++- homeassistant/components/wyoming/error.py | 10 +++ homeassistant/components/wyoming/stt.py | 7 +- homeassistant/components/wyoming/tts.py | 14 +++- homeassistant/components/wyoming/wake_word.py | 9 ++- tests/components/wyoming/test_conversation.py | 71 +++++++++++++++++++ tests/components/wyoming/test_stt.py | 39 ++++++++++ tests/components/wyoming/test_tts.py | 57 +++++++++++++++ tests/components/wyoming/test_wake_word.py | 41 +++++++++++ 9 files changed, 258 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/wyoming/conversation.py b/homeassistant/components/wyoming/conversation.py index 6cd091868eca..f70baf7ce0ca 100644 --- a/homeassistant/components/wyoming/conversation.py +++ b/homeassistant/components/wyoming/conversation.py @@ -6,6 +6,7 @@ from typing import Any, Literal, override from wyoming.asr import Transcript from wyoming.client import AsyncTcpClient +from wyoming.error import Error from wyoming.handle import Handled, NotHandled from wyoming.info import HandleProgram, IntentProgram from wyoming.intent import Intent, IntentsStart, IntentsStop, NotRecognized @@ -20,7 +21,7 @@ from homeassistant.util import ulid as ulid_util from .const import DOMAIN from .data import WyomingService -from .error import WyomingError +from .error import WyomingError, error_event_message from .models import WyomingConfigEntry _LOGGER = logging.getLogger(__name__) @@ -180,6 +181,17 @@ class WyomingConversationEntity( if event is None: raise WyomingError("Connection lost") + if Error.is_type(event.type): + message = error_event_message(Error.from_event(event)) + _LOGGER.error(message) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, message + ) + + # Don't process any intents that were already received + intents.clear() + break + if IntentsStart.is_type(event.type): # Multiple intents may be present has_intents_list = True diff --git a/homeassistant/components/wyoming/error.py b/homeassistant/components/wyoming/error.py index 0ccd35462b76..de5da945484c 100644 --- a/homeassistant/components/wyoming/error.py +++ b/homeassistant/components/wyoming/error.py @@ -1,7 +1,17 @@ """Errors for the Wyoming integration.""" +from wyoming.error import Error + from homeassistant.exceptions import HomeAssistantError class WyomingError(HomeAssistantError): """Base class for Wyoming errors.""" + + +def error_event_message(error: Error) -> str: + """Return a message for an error event from a Wyoming service.""" + if error.code is None: + return f"Error from Wyoming service: {error.text}" + + return f"Error from Wyoming service: {error.text} (code: {error.code})" diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py index df5e8f3a3767..fdb3352d9167 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -7,6 +7,7 @@ from typing import override from wyoming.asr import Transcribe, Transcript from wyoming.audio import AudioChunk, AudioStart, AudioStop from wyoming.client import AsyncTcpClient +from wyoming.error import Error from homeassistant.components import stt from homeassistant.core import HomeAssistant @@ -14,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH from .data import WyomingService -from .error import WyomingError +from .error import WyomingError, error_event_message from .models import WyomingConfigEntry _LOGGER = logging.getLogger(__name__) @@ -128,6 +129,10 @@ class WyomingSttProvider(stt.SpeechToTextEntity): _LOGGER.debug("Connection lost") return stt.SpeechResult(None, stt.SpeechResultState.ERROR) + if Error.is_type(event.type): + _LOGGER.error(error_event_message(Error.from_event(event))) + return stt.SpeechResult(None, stt.SpeechResultState.ERROR) + if Transcript.is_type(event.type): transcript = Transcript.from_event(event) text = transcript.text diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index 2af806c0e620..58be8bd485a9 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -9,6 +9,7 @@ import wave from wyoming.audio import AudioChunk, AudioStart, AudioStop from wyoming.client import AsyncTcpClient +from wyoming.error import Error from wyoming.tts import ( Synthesize, SynthesizeChunk, @@ -20,11 +21,12 @@ from wyoming.tts import ( from homeassistant.components import tts from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_SPEAKER from .data import WyomingService -from .error import WyomingError +from .error import WyomingError, error_event_message from .models import WyomingConfigEntry _LOGGER = logging.getLogger(__name__) @@ -117,6 +119,11 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): _LOGGER.debug("Connection lost") return (None, None) + if Error.is_type(event.type): + raise HomeAssistantError( + error_event_message(Error.from_event(event)) + ) + if AudioStop.is_type(event.type): break @@ -213,6 +220,11 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): try: while event := await client.read_event(): + if Error.is_type(event.type): + raise HomeAssistantError( + error_event_message(Error.from_event(event)) + ) + if wav_header_sent and AudioChunk.is_type(event.type): # PCM audio yield AudioChunk.from_event(event).audio diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index bbe1aeb40c13..ef47b9ad1b39 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -7,6 +7,7 @@ from typing import override from wyoming.audio import AudioChunk, AudioStart from wyoming.client import AsyncTcpClient +from wyoming.error import Error from wyoming.wake import Detect, Detection from homeassistant.components import wake_word @@ -14,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import WyomingService, load_wyoming_info -from .error import WyomingError +from .error import WyomingError, error_event_message from .models import WyomingConfigEntry _LOGGER = logging.getLogger(__name__) @@ -124,6 +125,12 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): _LOGGER.debug("Connection lost") break + if Error.is_type(event.type): + _LOGGER.error( + error_event_message(Error.from_event(event)) + ) + break + if Detection.is_type(event.type): # Possible detection detection = Detection.from_event(event) diff --git a/tests/components/wyoming/test_conversation.py b/tests/components/wyoming/test_conversation.py index 1592e4af0110..d210328cd583 100644 --- a/tests/components/wyoming/test_conversation.py +++ b/tests/components/wyoming/test_conversation.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion from wyoming.asr import Transcript +from wyoming.error import Error from wyoming.handle import Handled, NotHandled from wyoming.info import Info from wyoming.intent import Entity, Intent, IntentsStart, IntentsStop, NotRecognized @@ -403,6 +404,76 @@ async def test_not_handled( assert result.response.speech.get("plain", {}).get("speech") == "failure" +@pytest.mark.usefixtures("init_wyoming_intent") +@pytest.mark.parametrize( + ("error_code", "expected_message"), + [ + pytest.param(None, "Error from Wyoming service: Boom!", id="without_code"), + pytest.param( + "IntentError", + "Error from Wyoming service: Boom! (code: IntentError)", + id="with_code", + ), + ], +) +async def test_error_event( + hass: HomeAssistant, error_code: str | None, expected_message: str +) -> None: + """Test that an error event from the service is reported.""" + agent_id = "conversation.test_intent" + + with patch( + "homeassistant.components.wyoming.conversation.AsyncTcpClient", + MockAsyncTcpClient([Error(text="Boom!", code=error_code).event()]), + ): + result = await conversation.async_converse( + hass=hass, + text="test text", + conversation_id=None, + context=Context(), + language=hass.config.language, + agent_id=agent_id, + ) + + assert result.response.response_type is intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.UNKNOWN + assert result.response.speech, "No speech" + assert result.response.speech.get("plain", {}).get("speech") == expected_message + + +@pytest.mark.usefixtures("init_wyoming_intent") +async def test_error_event_discards_received_intents(hass: HomeAssistant) -> None: + """Test that intents received before an error event are not handled.""" + agent_id = "conversation.test_intent" + + with patch( + "homeassistant.components.wyoming.conversation.AsyncTcpClient", + MockAsyncTcpClient( + [ + IntentsStart().event(), + Intent(name="TestIntent").event(), + Error(text="Boom!").event(), + ] + ), + ): + result = await conversation.async_converse( + hass=hass, + text="test text", + conversation_id=None, + context=Context(), + language=hass.config.language, + agent_id=agent_id, + ) + + assert result.response.response_type is intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.UNKNOWN + assert result.response.speech, "No speech" + assert ( + result.response.speech.get("plain", {}).get("speech") + == "Error from Wyoming service: Boom!" + ) + + async def test_connection_lost( hass: HomeAssistant, init_wyoming_handle: ConfigEntry, snapshot: SnapshotAssertion ) -> None: diff --git a/tests/components/wyoming/test_stt.py b/tests/components/wyoming/test_stt.py index b7a52c3b792d..ab78ad503337 100644 --- a/tests/components/wyoming/test_stt.py +++ b/tests/components/wyoming/test_stt.py @@ -2,8 +2,10 @@ from unittest.mock import patch +import pytest from syrupy.assertion import SnapshotAssertion from wyoming.asr import Transcript +from wyoming.error import Error from homeassistant.components import stt from homeassistant.core import HomeAssistant @@ -69,6 +71,43 @@ async def test_streaming_audio_connection_lost( assert result.text is None +@pytest.mark.usefixtures("init_wyoming_stt") +@pytest.mark.parametrize( + ("error_code", "expected_message"), + [ + pytest.param(None, "Error from Wyoming service: Boom!", id="without_code"), + pytest.param( + "ModelNotFoundError", + "Error from Wyoming service: Boom! (code: ModelNotFoundError)", + id="with_code", + ), + ], +) +async def test_streaming_audio_error_event( + hass: HomeAssistant, + metadata: stt.SpeechMetadata, + caplog: pytest.LogCaptureFixture, + error_code: str | None, + expected_message: str, +) -> None: + """Test that an error event from the service is reported.""" + entity = stt.async_get_speech_to_text_entity(hass, "stt.test_asr") + assert entity is not None + + async def audio_stream(): + yield "chunk1" + + with patch( + "homeassistant.components.wyoming.stt.AsyncTcpClient", + MockAsyncTcpClient([Error(text="Boom!", code=error_code).event()]), + ): + result = await entity.async_process_audio_stream(metadata, audio_stream()) + + assert result.result == stt.SpeechResultState.ERROR + assert result.text is None + assert expected_message in caplog.text + + async def test_streaming_audio_oserror( hass: HomeAssistant, init_wyoming_stt, metadata ) -> None: diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index e56af1d8c548..3269292ce5e1 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -1,12 +1,14 @@ """Test tts.""" import io +import re from unittest.mock import patch import wave import pytest from syrupy.assertion import SnapshotAssertion from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.error import Error from wyoming.tts import SynthesizeStopped from homeassistant.components import tts, wyoming @@ -196,6 +198,61 @@ async def test_get_tts_audio_audio_oserror( ) +@pytest.mark.usefixtures("init_wyoming_tts") +@pytest.mark.parametrize( + ("error_code", "expected_message"), + [ + pytest.param(None, "Error from Wyoming service: Boom!", id="without_code"), + pytest.param( + "VoiceNotFoundError", + "Error from Wyoming service: Boom! (code: VoiceNotFoundError)", + id="with_code", + ), + ], +) +async def test_get_tts_audio_error_event( + hass: HomeAssistant, error_code: str | None, expected_message: str +) -> None: + """Test that an error event from the service is reported.""" + with ( + patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient([Error(text="Boom!", code=error_code).event()]), + ), + pytest.raises(HomeAssistantError, match=re.escape(expected_message)), + ): + await tts.async_get_media_source_audio( + hass, + tts.generate_media_source_id(hass, "Hello world", "tts.test_tts", "en-US"), + ) + + +@pytest.mark.usefixtures("init_wyoming_streaming_tts") +async def test_get_tts_audio_streaming_error_event(hass: HomeAssistant) -> None: + """Test that an error event received while streaming is reported.""" + + async def message_gen(): + yield "Hello world." + + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient([Error(text="Boom!").event()]), + ): + stream = tts.async_create_stream( + hass, + "tts.test_streaming_tts", + "en-US", + options={tts.ATTR_PREFERRED_FORMAT: "wav"}, + ) + stream.async_set_message_stream(message_gen()) + + with pytest.raises( + HomeAssistantError, match="Error from Wyoming service: Boom!" + ): + async for _chunk in stream.async_stream_result(): + pass + + async def test_voice_speaker( hass: HomeAssistant, init_wyoming_tts, snapshot: SnapshotAssertion ) -> None: diff --git a/tests/components/wyoming/test_wake_word.py b/tests/components/wyoming/test_wake_word.py index 9eebbf84da4d..08e8e3aace06 100644 --- a/tests/components/wyoming/test_wake_word.py +++ b/tests/components/wyoming/test_wake_word.py @@ -3,8 +3,10 @@ import asyncio from unittest.mock import patch +import pytest from syrupy.assertion import SnapshotAssertion from wyoming.asr import Transcript +from wyoming.error import Error from wyoming.info import Info, WakeModel, WakeProgram from wyoming.wake import Detection @@ -85,6 +87,45 @@ async def test_streaming_audio_connection_lost( assert result is None +@pytest.mark.usefixtures("init_wyoming_wake_word") +@pytest.mark.parametrize( + ("error_code", "expected_message"), + [ + pytest.param(None, "Error from Wyoming service: Boom!", id="without_code"), + pytest.param( + "ModelNotFoundError", + "Error from Wyoming service: Boom! (code: ModelNotFoundError)", + id="with_code", + ), + ], +) +async def test_streaming_audio_error_event( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + error_code: str | None, + expected_message: str, +) -> None: + """Test that an error event from the service is reported.""" + entity = wake_word.async_get_wake_word_detection_entity( + hass, "wake_word.test_wake_word" + ) + assert entity is not None + + async def audio_stream(): + # Delay to force a pending audio chunk + await asyncio.sleep(0.05) + yield b"chunk", 1 + + with patch( + "homeassistant.components.wyoming.wake_word.AsyncTcpClient", + MockAsyncTcpClient([Error(text="Boom!", code=error_code).event()]), + ): + result = await entity.async_process_audio_stream(audio_stream(), None) + + assert result is None + assert expected_message in caplog.text + + async def test_streaming_audio_oserror( hass: HomeAssistant, init_wyoming_wake_word ) -> None: