From bac63f7cdbe9388a8d5d851f0221b8d26a9daa59 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 6 Mar 2025 11:25:15 -0600 Subject: [PATCH 1/4] Add info websocket command to wyoming integration --- homeassistant/components/wyoming/__init__.py | 10 +++ .../components/wyoming/websocket_api.py | 42 +++++++++++++ tests/components/wyoming/conftest.py | 16 ++++- tests/components/wyoming/test_websocket.py | 61 +++++++++++++++++++ 4 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/wyoming/websocket_api.py create mode 100644 tests/components/wyoming/test_websocket.py diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index d639933ece6..51edc567f1a 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -9,11 +9,13 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import ConfigType from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService from .devices import SatelliteDevice from .models import DomainDataItem +from .websocket_api import async_register_websocket_api _LOGGER = logging.getLogger(__name__) @@ -28,11 +30,19 @@ SATELLITE_PLATFORMS = [ __all__ = [ "ATTR_SPEAKER", "DOMAIN", + "async_setup", "async_setup_entry", "async_unload_entry", ] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Wyoming integration.""" + async_register_websocket_api(hass) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load Wyoming.""" service = await WyomingService.create(entry.data["host"], entry.data["port"]) diff --git a/homeassistant/components/wyoming/websocket_api.py b/homeassistant/components/wyoming/websocket_api.py new file mode 100644 index 00000000000..613238c302a --- /dev/null +++ b/homeassistant/components/wyoming/websocket_api.py @@ -0,0 +1,42 @@ +"""Wyoming Websocket API.""" + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .models import DomainDataItem + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_register_websocket_api(hass: HomeAssistant) -> None: + """Register the websocket API.""" + websocket_api.async_register_command(hass, websocket_info) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "wyoming/info"}) +def websocket_info( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List service information for Wyoming all config entries.""" + entry_items: dict[str, DomainDataItem] = hass.data.get(DOMAIN, {}) + + connection.send_result( + msg["id"], + { + "info": { + entry_id: item.service.info.to_dict() + for entry_id, item in entry_items.items() + } + }, + ) diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 018fff33821..125edc547c6 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -121,7 +121,9 @@ def handle_config_entry(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture -async def init_wyoming_stt(hass: HomeAssistant, stt_config_entry: ConfigEntry): +async def init_wyoming_stt( + hass: HomeAssistant, stt_config_entry: ConfigEntry +) -> ConfigEntry: """Initialize Wyoming STT.""" with patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -129,9 +131,13 @@ async def init_wyoming_stt(hass: HomeAssistant, stt_config_entry: ConfigEntry): ): await hass.config_entries.async_setup(stt_config_entry.entry_id) + return stt_config_entry + @pytest.fixture -async def init_wyoming_tts(hass: HomeAssistant, tts_config_entry: ConfigEntry): +async def init_wyoming_tts( + hass: HomeAssistant, tts_config_entry: ConfigEntry +) -> ConfigEntry: """Initialize Wyoming TTS.""" with patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -139,11 +145,13 @@ async def init_wyoming_tts(hass: HomeAssistant, tts_config_entry: ConfigEntry): ): await hass.config_entries.async_setup(tts_config_entry.entry_id) + return tts_config_entry + @pytest.fixture async def init_wyoming_wake_word( hass: HomeAssistant, wake_word_config_entry: ConfigEntry -): +) -> ConfigEntry: """Initialize Wyoming Wake Word.""" with patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -151,6 +159,8 @@ async def init_wyoming_wake_word( ): await hass.config_entries.async_setup(wake_word_config_entry.entry_id) + return wake_word_config_entry + @pytest.fixture async def init_wyoming_intent( diff --git a/tests/components/wyoming/test_websocket.py b/tests/components/wyoming/test_websocket.py new file mode 100644 index 00000000000..168561d7459 --- /dev/null +++ b/tests/components/wyoming/test_websocket.py @@ -0,0 +1,61 @@ +"""Websocket tests for Wyoming integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from tests.typing import WebSocketGenerator + + +async def test_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + init_wyoming_stt: ConfigEntry, + init_wyoming_tts: ConfigEntry, + init_wyoming_wake_word: ConfigEntry, + init_wyoming_intent: ConfigEntry, + init_wyoming_handle: ConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test info websocket command.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "wyoming/info"}) + + # result + msg = await client.receive_json() + assert msg["success"] + + info = msg.get("result", {}).get("info", {}) + + # stt (speech-to-text) = asr (automated speech recognition) + assert init_wyoming_stt.entry_id in info + asr_info = info[init_wyoming_stt.entry_id].get("asr", []) + assert len(asr_info) == 1 + assert asr_info[0].get("name") == "Test ASR" + + # tts (text-to-speech) + assert init_wyoming_tts.entry_id in info + tts_info = info[init_wyoming_tts.entry_id].get("tts", []) + assert len(tts_info) == 1 + assert tts_info[0].get("name") == "Test TTS" + + # wake word detection + assert init_wyoming_wake_word.entry_id in info + wake_info = info[init_wyoming_wake_word.entry_id].get("wake", []) + assert len(wake_info) == 1 + assert wake_info[0].get("name") == "Test Wake Word" + + # intent recognition + assert init_wyoming_intent.entry_id in info + intent_info = info[init_wyoming_intent.entry_id].get("intent", []) + assert len(intent_info) == 1 + assert intent_info[0].get("name") == "Test Intent" + + # intent handling + assert init_wyoming_handle.entry_id in info + handle_info = info[init_wyoming_handle.entry_id].get("handle", []) + assert len(handle_info) == 1 + assert handle_info[0].get("name") == "Test Handle" From 08bb3a6aace9143a6ef07139bced2157aa814582 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 6 Mar 2025 11:37:39 -0600 Subject: [PATCH 2/4] Add snapshot --- .../wyoming/snapshots/test_websocket.ambr | 196 ++++++++++++++++++ tests/components/wyoming/test_websocket.py | 1 + 2 files changed, 197 insertions(+) create mode 100644 tests/components/wyoming/snapshots/test_websocket.ambr diff --git a/tests/components/wyoming/snapshots/test_websocket.ambr b/tests/components/wyoming/snapshots/test_websocket.ambr new file mode 100644 index 00000000000..777b99aca31 --- /dev/null +++ b/tests/components/wyoming/snapshots/test_websocket.ambr @@ -0,0 +1,196 @@ +# serializer version: 1 +# name: test_info + dict({ + '01JNP8HAYHF6G5V0QJX6HBC94T': dict({ + 'asr': list([ + dict({ + 'attribution': dict({ + 'name': 'Test', + 'url': 'http://www.test.com', + }), + 'description': 'Test ASR', + 'installed': True, + 'models': list([ + dict({ + 'attribution': dict({ + 'name': 'Test', + 'url': 'http://www.test.com', + }), + 'description': 'Test Model', + 'installed': True, + 'languages': list([ + 'en-US', + ]), + 'name': 'Test Model', + 'version': None, + }), + ]), + 'name': 'Test ASR', + 'version': None, + }), + ]), + 'handle': list([ + ]), + 'intent': list([ + ]), + 'tts': list([ + ]), + 'wake': list([ + ]), + }), + '01JNP8HB1MMF0HE8M42C8K8XEH': dict({ + 'asr': list([ + ]), + 'handle': list([ + ]), + 'intent': list([ + ]), + 'tts': list([ + dict({ + 'attribution': dict({ + 'name': 'Test', + 'url': 'http://www.test.com', + }), + 'description': 'Test TTS', + 'installed': True, + 'name': 'Test TTS', + 'version': None, + 'voices': list([ + dict({ + 'attribution': dict({ + 'name': 'Test', + 'url': 'http://www.test.com', + }), + 'description': 'Test Voice', + 'installed': True, + 'languages': list([ + 'en-US', + ]), + 'name': 'Test Voice', + 'speakers': list([ + dict({ + 'name': 'Test Speaker', + }), + ]), + 'version': None, + }), + ]), + }), + ]), + 'wake': list([ + ]), + }), + '01JNP8HB1SJFFVX809QVAEQQPK': dict({ + 'asr': list([ + ]), + 'handle': list([ + ]), + 'intent': list([ + ]), + 'tts': list([ + ]), + 'wake': list([ + dict({ + 'attribution': dict({ + 'name': 'Test', + 'url': 'http://www.test.com', + }), + 'description': 'Test Wake Word', + 'installed': True, + 'models': list([ + dict({ + 'attribution': dict({ + 'name': 'Test', + 'url': 'http://www.test.com', + }), + 'description': 'Test Model', + 'installed': True, + 'languages': list([ + 'en-US', + ]), + 'name': 'Test Model', + 'phrase': 'Test Phrase', + 'version': None, + }), + ]), + 'name': 'Test Wake Word', + 'version': None, + }), + ]), + }), + '01JNP8HB1XY1S5BP3E01BSHN1V': dict({ + 'asr': list([ + ]), + 'handle': list([ + ]), + 'intent': list([ + dict({ + 'attribution': dict({ + 'name': 'Test', + 'url': 'http://www.test.com', + }), + 'description': 'Test Intent', + 'installed': True, + 'models': list([ + dict({ + 'attribution': dict({ + 'name': 'Test', + 'url': 'http://www.test.com', + }), + 'description': 'Test Model', + 'installed': True, + 'languages': list([ + 'en-US', + ]), + 'name': 'Test Model', + 'version': None, + }), + ]), + 'name': 'Test Intent', + 'version': None, + }), + ]), + 'tts': list([ + ]), + 'wake': list([ + ]), + }), + '01JNP8HB233HE975X9972MJN1G': dict({ + 'asr': list([ + ]), + 'handle': list([ + dict({ + 'attribution': dict({ + 'name': 'Test', + 'url': 'http://www.test.com', + }), + 'description': 'Test Handle', + 'installed': True, + 'models': list([ + dict({ + 'attribution': dict({ + 'name': 'Test', + 'url': 'http://www.test.com', + }), + 'description': 'Test Model', + 'installed': True, + 'languages': list([ + 'en-US', + ]), + 'name': 'Test Model', + 'version': None, + }), + ]), + 'name': 'Test Handle', + 'version': None, + }), + ]), + 'intent': list([ + ]), + 'tts': list([ + ]), + 'wake': list([ + ]), + }), + }) +# --- diff --git a/tests/components/wyoming/test_websocket.py b/tests/components/wyoming/test_websocket.py index 168561d7459..37c4b6332b5 100644 --- a/tests/components/wyoming/test_websocket.py +++ b/tests/components/wyoming/test_websocket.py @@ -29,6 +29,7 @@ async def test_info( assert msg["success"] info = msg.get("result", {}).get("info", {}) + assert info == snapshot # stt (speech-to-text) = asr (automated speech recognition) assert init_wyoming_stt.entry_id in info From 1162291c8773b25d09594a47cbff1445ab28ffb2 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 6 Mar 2025 11:45:51 -0600 Subject: [PATCH 3/4] Add config schema --- homeassistant/components/wyoming/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 51edc567f1a..4e76287d8e7 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from .const import ATTR_SPEAKER, DOMAIN @@ -19,6 +19,8 @@ from .websocket_api import async_register_websocket_api _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + SATELLITE_PLATFORMS = [ Platform.ASSIST_SATELLITE, Platform.BINARY_SENSOR, From 5c58bc17265e66bee26faa5248427983c5be8e81 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 6 Mar 2025 11:48:43 -0600 Subject: [PATCH 4/4] Remove snapshots because of changing config entry ids --- .../wyoming/snapshots/test_websocket.ambr | 196 ------------------ tests/components/wyoming/test_websocket.py | 4 - 2 files changed, 200 deletions(-) delete mode 100644 tests/components/wyoming/snapshots/test_websocket.ambr diff --git a/tests/components/wyoming/snapshots/test_websocket.ambr b/tests/components/wyoming/snapshots/test_websocket.ambr deleted file mode 100644 index 777b99aca31..00000000000 --- a/tests/components/wyoming/snapshots/test_websocket.ambr +++ /dev/null @@ -1,196 +0,0 @@ -# serializer version: 1 -# name: test_info - dict({ - '01JNP8HAYHF6G5V0QJX6HBC94T': dict({ - 'asr': list([ - dict({ - 'attribution': dict({ - 'name': 'Test', - 'url': 'http://www.test.com', - }), - 'description': 'Test ASR', - 'installed': True, - 'models': list([ - dict({ - 'attribution': dict({ - 'name': 'Test', - 'url': 'http://www.test.com', - }), - 'description': 'Test Model', - 'installed': True, - 'languages': list([ - 'en-US', - ]), - 'name': 'Test Model', - 'version': None, - }), - ]), - 'name': 'Test ASR', - 'version': None, - }), - ]), - 'handle': list([ - ]), - 'intent': list([ - ]), - 'tts': list([ - ]), - 'wake': list([ - ]), - }), - '01JNP8HB1MMF0HE8M42C8K8XEH': dict({ - 'asr': list([ - ]), - 'handle': list([ - ]), - 'intent': list([ - ]), - 'tts': list([ - dict({ - 'attribution': dict({ - 'name': 'Test', - 'url': 'http://www.test.com', - }), - 'description': 'Test TTS', - 'installed': True, - 'name': 'Test TTS', - 'version': None, - 'voices': list([ - dict({ - 'attribution': dict({ - 'name': 'Test', - 'url': 'http://www.test.com', - }), - 'description': 'Test Voice', - 'installed': True, - 'languages': list([ - 'en-US', - ]), - 'name': 'Test Voice', - 'speakers': list([ - dict({ - 'name': 'Test Speaker', - }), - ]), - 'version': None, - }), - ]), - }), - ]), - 'wake': list([ - ]), - }), - '01JNP8HB1SJFFVX809QVAEQQPK': dict({ - 'asr': list([ - ]), - 'handle': list([ - ]), - 'intent': list([ - ]), - 'tts': list([ - ]), - 'wake': list([ - dict({ - 'attribution': dict({ - 'name': 'Test', - 'url': 'http://www.test.com', - }), - 'description': 'Test Wake Word', - 'installed': True, - 'models': list([ - dict({ - 'attribution': dict({ - 'name': 'Test', - 'url': 'http://www.test.com', - }), - 'description': 'Test Model', - 'installed': True, - 'languages': list([ - 'en-US', - ]), - 'name': 'Test Model', - 'phrase': 'Test Phrase', - 'version': None, - }), - ]), - 'name': 'Test Wake Word', - 'version': None, - }), - ]), - }), - '01JNP8HB1XY1S5BP3E01BSHN1V': dict({ - 'asr': list([ - ]), - 'handle': list([ - ]), - 'intent': list([ - dict({ - 'attribution': dict({ - 'name': 'Test', - 'url': 'http://www.test.com', - }), - 'description': 'Test Intent', - 'installed': True, - 'models': list([ - dict({ - 'attribution': dict({ - 'name': 'Test', - 'url': 'http://www.test.com', - }), - 'description': 'Test Model', - 'installed': True, - 'languages': list([ - 'en-US', - ]), - 'name': 'Test Model', - 'version': None, - }), - ]), - 'name': 'Test Intent', - 'version': None, - }), - ]), - 'tts': list([ - ]), - 'wake': list([ - ]), - }), - '01JNP8HB233HE975X9972MJN1G': dict({ - 'asr': list([ - ]), - 'handle': list([ - dict({ - 'attribution': dict({ - 'name': 'Test', - 'url': 'http://www.test.com', - }), - 'description': 'Test Handle', - 'installed': True, - 'models': list([ - dict({ - 'attribution': dict({ - 'name': 'Test', - 'url': 'http://www.test.com', - }), - 'description': 'Test Model', - 'installed': True, - 'languages': list([ - 'en-US', - ]), - 'name': 'Test Model', - 'version': None, - }), - ]), - 'name': 'Test Handle', - 'version': None, - }), - ]), - 'intent': list([ - ]), - 'tts': list([ - ]), - 'wake': list([ - ]), - }), - }) -# --- diff --git a/tests/components/wyoming/test_websocket.py b/tests/components/wyoming/test_websocket.py index 37c4b6332b5..18b43321354 100644 --- a/tests/components/wyoming/test_websocket.py +++ b/tests/components/wyoming/test_websocket.py @@ -1,7 +1,5 @@ """Websocket tests for Wyoming integration.""" -from syrupy.assertion import SnapshotAssertion - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -17,7 +15,6 @@ async def test_info( init_wyoming_wake_word: ConfigEntry, init_wyoming_intent: ConfigEntry, init_wyoming_handle: ConfigEntry, - snapshot: SnapshotAssertion, ) -> None: """Test info websocket command.""" client = await hass_ws_client(hass) @@ -29,7 +26,6 @@ async def test_info( assert msg["success"] info = msg.get("result", {}).get("info", {}) - assert info == snapshot # stt (speech-to-text) = asr (automated speech recognition) assert init_wyoming_stt.entry_id in info