mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Handle Wyoming error events (#178696)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Bram Kragten
co-authored by
Claude Opus 5
parent
73868479d1
commit
b4f5811c3d
@@ -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
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user