Handle Wyoming error events (#178696)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michael Hansen
2026-08-14 14:26:30 +02:00
committed by Bram Kragten
co-authored by Claude Opus 5
parent 73868479d1
commit b4f5811c3d
9 changed files with 258 additions and 4 deletions
@@ -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
+10
View File
@@ -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})"
+6 -1
View File
@@ -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
+13 -1
View File
@@ -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
@@ -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)
@@ -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:
+39
View File
@@ -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:
+57
View File
@@ -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:
@@ -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: