Add wyoming integration with stt (#91579)

* Add wyoming integration with stt/tts

* Forward config entry setup

* Use SpeechToTextEntity

* Add strings to config flow

* Move connection into config flow

* Add tests

* On load/unload used platforms

* Tweaks

* Add unload test

* Fix stt

* Add missing file

* Add test for no services

* Improve coverage

* Finish test coverage

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Michael Hansen
2023-04-19 06:10:59 -04:00
committed by GitHub
co-authored by Paulus Schoutsen
parent f74103c57e
commit 85d57a046c
19 changed files with 683 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
"""Tests for the Wyoming integration."""
from wyoming.info import AsrModel, AsrProgram, Attribution, Info
TEST_ATTR = Attribution(name="Test", url="http://www.test.com")
STT_INFO = Info(
asr=[
AsrProgram(
name="Test ASR",
installed=True,
attribution=TEST_ATTR,
models=[
AsrModel(
name="Test Model",
installed=True,
attribution=TEST_ATTR,
languages=["en-US"],
)
],
)
]
)
EMPTY_INFO = Info()
+46
View File
@@ -0,0 +1,46 @@
"""Common fixtures for the Wyoming tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from . import STT_INFO
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.wyoming.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def config_entry(hass: HomeAssistant) -> ConfigEntry:
"""Create a config entry."""
entry = MockConfigEntry(
domain="wyoming",
data={
"host": "1.2.3.4",
"port": 1234,
},
title="Test ASR",
)
entry.add_to_hass(hass)
return entry
@pytest.fixture
async def init_wyoming_stt(hass: HomeAssistant, config_entry: ConfigEntry):
"""Initialize Wyoming."""
with patch(
"homeassistant.components.wyoming.data.load_wyoming_info",
return_value=STT_INFO,
):
await hass.config_entries.async_setup(config_entry.entry_id)
@@ -0,0 +1,42 @@
# serializer version: 1
# name: test_streaming_audio
list([
dict({
'data': dict({
'channels': 1,
'rate': 16000,
'timestamp': None,
'width': 2,
}),
'payload': None,
'type': 'audio-start',
}),
dict({
'data': dict({
'channels': 1,
'rate': 16000,
'timestamp': None,
'width': 2,
}),
'payload': 'chunk1',
'type': 'audio-chunk',
}),
dict({
'data': dict({
'channels': 1,
'rate': 16000,
'timestamp': None,
'width': 2,
}),
'payload': 'chunk2',
'type': 'audio-chunk',
}),
dict({
'data': dict({
'timestamp': None,
}),
'payload': None,
'type': 'audio-stop',
}),
])
# ---
@@ -0,0 +1,87 @@
"""Test the Wyoming config flow."""
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant import config_entries
from homeassistant.components.wyoming.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import EMPTY_INFO, STT_INFO
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] is None
with patch(
"homeassistant.components.wyoming.data.load_wyoming_info",
return_value=STT_INFO,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
"port": 1234,
},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "Test ASR"
assert result2["data"] == {
"host": "1.1.1.1",
"port": 1234,
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.wyoming.data.load_wyoming_info",
return_value=None,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
"port": 1234,
},
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
async def test_no_supported_services(hass: HomeAssistant) -> None:
"""Test we handle no supported services error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.wyoming.data.load_wyoming_info",
return_value=EMPTY_INFO,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
"port": 1234,
},
)
assert result2["type"] == FlowResultType.ABORT
assert result2["reason"] == "no_services"
+21
View File
@@ -0,0 +1,21 @@
"""Test init."""
from unittest.mock import patch
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
async def test_cannot_connect(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Test we handle cannot connect error."""
with patch(
"homeassistant.components.wyoming.data.load_wyoming_info",
return_value=None,
):
assert not await hass.config_entries.async_setup(config_entry.entry_id)
async def test_unload(
hass: HomeAssistant, config_entry: ConfigEntry, init_wyoming_stt
) -> None:
"""Test unload."""
assert await hass.config_entries.async_unload(config_entry.entry_id)
+115
View File
@@ -0,0 +1,115 @@
"""Test stt."""
from __future__ import annotations
from unittest.mock import patch
from wyoming.event import Event
from homeassistant.components import stt
from homeassistant.core import HomeAssistant
class MockAsyncTcpClient:
"""Mock AsyncTcpClient."""
def __init__(self, responses) -> None:
"""Initialize."""
self.host = None
self.port = None
self.written = []
self.responses = responses
async def write_event(self, event):
"""Send."""
self.written.append(event)
async def read_event(self):
"""Receive."""
return self.responses.pop(0)
async def __aenter__(self):
"""Enter."""
return self
async def __aexit__(self, exc_type, exc, tb):
"""Exit."""
def __call__(self, host, port):
"""Call."""
self.host = host
self.port = port
return self
async def test_support(hass: HomeAssistant, init_wyoming_stt) -> None:
"""Test streaming audio."""
state = hass.states.get("stt.wyoming")
assert state is not None
entity = stt.async_get_speech_to_text_entity(hass, "stt.wyoming")
assert entity.supported_languages == ["en-US"]
assert entity.supported_formats == [stt.AudioFormats.WAV]
assert entity.supported_codecs == [stt.AudioCodecs.PCM]
assert entity.supported_bit_rates == [stt.AudioBitRates.BITRATE_16]
assert entity.supported_sample_rates == [stt.AudioSampleRates.SAMPLERATE_16000]
assert entity.supported_channels == [stt.AudioChannels.CHANNEL_MONO]
async def test_streaming_audio(hass: HomeAssistant, init_wyoming_stt, snapshot) -> None:
"""Test streaming audio."""
entity = stt.async_get_speech_to_text_entity(hass, "stt.wyoming")
async def audio_stream():
yield "chunk1"
yield "chunk2"
with patch(
"homeassistant.components.wyoming.stt.AsyncTcpClient",
MockAsyncTcpClient([Event(type="transcript", data={"text": "Hello world"})]),
) as mock_client:
result = await entity.async_process_audio_stream(None, audio_stream())
assert result.result == stt.SpeechResultState.SUCCESS
assert result.text == "Hello world"
assert mock_client.written == snapshot
async def test_streaming_audio_connection_lost(
hass: HomeAssistant, init_wyoming_stt
) -> None:
"""Test streaming audio and losing connection."""
entity = stt.async_get_speech_to_text_entity(hass, "stt.wyoming")
async def audio_stream():
yield "chunk1"
with patch(
"homeassistant.components.wyoming.stt.AsyncTcpClient",
MockAsyncTcpClient([None]),
):
result = await entity.async_process_audio_stream(None, audio_stream())
assert result.result == stt.SpeechResultState.ERROR
assert result.text is None
async def test_streaming_audio_oserror(hass: HomeAssistant, init_wyoming_stt) -> None:
"""Test streaming audio and error raising."""
entity = stt.async_get_speech_to_text_entity(hass, "stt.wyoming")
async def audio_stream():
yield "chunk1"
mock_client = MockAsyncTcpClient(
[Event(type="transcript", data={"text": "Hello world"})]
)
with patch(
"homeassistant.components.wyoming.stt.AsyncTcpClient",
mock_client,
), patch.object(mock_client, "read_event", side_effect=OSError("Boom!")):
result = await entity.async_process_audio_stream(None, audio_stream())
assert result.result == stt.SpeechResultState.ERROR
assert result.text is None