Compare commits

...

10 Commits

Author SHA1 Message Date
Bram Kragten
1da66faac6 Merge branch 'synesthesiam-20250312-language-scores' into test-voice 2025-03-26 09:08:12 +01:00
Bram Kragten
c4e9aeefea Merge branch 'synesthesiam-20250306-wyoming-info-websocket' into test-voice 2025-03-26 09:07:51 +01:00
Michael Hansen
865e4b1603 Bump intents to 2025.3.24 for dash lang code 2025-03-24 14:33:00 -05:00
Michael Hansen
6a3ea03689 Add language/country args for preferred lang 2025-03-24 14:31:10 -05:00
Michael Hansen
c2932debde Don't store language scores in snapshot 2025-03-24 09:09:40 -05:00
Michael Hansen
222330e7c5 Add language scores websocket command 2025-03-24 09:09:38 -05:00
Michael Hansen
5c58bc1726 Remove snapshots because of changing config entry ids 2025-03-06 11:48:43 -06:00
Michael Hansen
1162291c87 Add config schema 2025-03-06 11:45:51 -06:00
Michael Hansen
08bb3a6aac Add snapshot 2025-03-06 11:37:39 -06:00
Michael Hansen
bac63f7cdb Add info websocket command to wyoming integration 2025-03-06 11:25:15 -06:00
11 changed files with 221 additions and 9 deletions

View File

@@ -3,11 +3,13 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import asdict
from typing import Any from typing import Any
from aiohttp import web from aiohttp import web
from hassil.recognize import MISSING_ENTITY, RecognizeResult from hassil.recognize import MISSING_ENTITY, RecognizeResult
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
from home_assistant_intents import get_language_scores
import voluptuous as vol import voluptuous as vol
from homeassistant.components import http, websocket_api from homeassistant.components import http, websocket_api
@@ -38,6 +40,7 @@ def async_setup(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_list_agents) websocket_api.async_register_command(hass, websocket_list_agents)
websocket_api.async_register_command(hass, websocket_list_sentences) websocket_api.async_register_command(hass, websocket_list_sentences)
websocket_api.async_register_command(hass, websocket_hass_agent_debug) websocket_api.async_register_command(hass, websocket_hass_agent_debug)
websocket_api.async_register_command(hass, websocket_hass_agent_language_scores)
@websocket_api.websocket_command( @websocket_api.websocket_command(
@@ -336,6 +339,36 @@ def _get_unmatched_slots(
return unmatched_slots return unmatched_slots
@websocket_api.websocket_command(
{
vol.Required("type"): "conversation/agent/homeassistant/language_scores",
vol.Optional("language"): str,
vol.Optional("country"): str,
}
)
@websocket_api.async_response
async def websocket_hass_agent_language_scores(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get support scores per language."""
language = msg.get("language", hass.config.language)
country = msg.get("country", hass.config.country)
scores = await hass.async_add_executor_job(get_language_scores)
matching_langs = language_util.matches(language, scores.keys(), country=country)
preferred_lang = matching_langs[0] if matching_langs else language
result = {
"languages": {
lang_key: asdict(lang_scores) for lang_key, lang_scores in scores.items()
},
"preferred_language": preferred_lang,
}
connection.send_result(msg["id"], result)
class ConversationProcessView(http.HomeAssistantView): class ConversationProcessView(http.HomeAssistantView):
"""View to process text.""" """View to process text."""

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation", "documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.23"] "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.24"]
} }

View File

@@ -8,15 +8,19 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady 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 from .const import ATTR_SPEAKER, DOMAIN
from .data import WyomingService from .data import WyomingService
from .devices import SatelliteDevice from .devices import SatelliteDevice
from .models import DomainDataItem from .models import DomainDataItem
from .websocket_api import async_register_websocket_api
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
SATELLITE_PLATFORMS = [ SATELLITE_PLATFORMS = [
Platform.ASSIST_SATELLITE, Platform.ASSIST_SATELLITE,
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
@@ -28,11 +32,19 @@ SATELLITE_PLATFORMS = [
__all__ = [ __all__ = [
"ATTR_SPEAKER", "ATTR_SPEAKER",
"DOMAIN", "DOMAIN",
"async_setup",
"async_setup_entry", "async_setup_entry",
"async_unload_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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Load Wyoming.""" """Load Wyoming."""
service = await WyomingService.create(entry.data["host"], entry.data["port"]) service = await WyomingService.create(entry.data["host"], entry.data["port"])

View File

@@ -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()
}
},
)

View File

@@ -39,7 +39,7 @@ hass-nabucasa==0.94.0
hassil==2.2.3 hassil==2.2.3
home-assistant-bluetooth==1.13.1 home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250306.0 home-assistant-frontend==20250306.0
home-assistant-intents==2025.3.23 home-assistant-intents==2025.3.24
httpx==0.28.1 httpx==0.28.1
ifaddr==0.2.0 ifaddr==0.2.0
Jinja2==3.1.6 Jinja2==3.1.6

2
requirements_all.txt generated
View File

@@ -1157,7 +1157,7 @@ holidays==0.69
home-assistant-frontend==20250306.0 home-assistant-frontend==20250306.0
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2025.3.23 home-assistant-intents==2025.3.24
# homeassistant.components.homematicip_cloud # homeassistant.components.homematicip_cloud
homematicip==1.1.7 homematicip==1.1.7

View File

@@ -984,7 +984,7 @@ holidays==0.69
home-assistant-frontend==20250306.0 home-assistant-frontend==20250306.0
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2025.3.23 home-assistant-intents==2025.3.24
# homeassistant.components.homematicip_cloud # homeassistant.components.homematicip_cloud
homematicip==1.1.7 homematicip==1.1.7

View File

@@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.8,source=/uv,target=/bin/uv \
-c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \
-r /usr/src/homeassistant/requirements.txt \ -r /usr/src/homeassistant/requirements.txt \
stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.11.0 \ stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.11.0 \
PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.23 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.24 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
LABEL "name"="hassfest" LABEL "name"="hassfest"
LABEL "maintainer"="Home Assistant <hello@home-assistant.io>" LABEL "maintainer"="Home Assistant <hello@home-assistant.io>"

View File

@@ -536,3 +536,60 @@ async def test_ws_hass_agent_debug_sentence_trigger(
# Trigger should not have been executed # Trigger should not have been executed
assert len(calls) == 0 assert len(calls) == 0
async def test_ws_hass_language_scores(
hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator
) -> None:
"""Test getting language support scores."""
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{"type": "conversation/agent/homeassistant/language_scores"}
)
msg = await client.receive_json()
assert msg["success"]
# Sanity check
result = msg["result"]
assert result["languages"]["en-US"] == {
"cloud": 3,
"focused_local": 2,
"full_local": 3,
}
async def test_ws_hass_language_scores_with_filter(
hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator
) -> None:
"""Test getting language support scores with language/country filter."""
client = await hass_ws_client(hass)
# Language filter
await client.send_json_auto_id(
{"type": "conversation/agent/homeassistant/language_scores", "language": "de"}
)
msg = await client.receive_json()
assert msg["success"]
# German should be preferred
result = msg["result"]
assert result["preferred_language"] == "de-DE"
# Language/country filter
await client.send_json_auto_id(
{
"type": "conversation/agent/homeassistant/language_scores",
"language": "en",
"country": "GB",
}
)
msg = await client.receive_json()
assert msg["success"]
# GB English should be preferred
result = msg["result"]
assert result["preferred_language"] == "en-GB"

View File

@@ -121,7 +121,9 @@ def handle_config_entry(hass: HomeAssistant) -> ConfigEntry:
@pytest.fixture @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.""" """Initialize Wyoming STT."""
with patch( with patch(
"homeassistant.components.wyoming.data.load_wyoming_info", "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) await hass.config_entries.async_setup(stt_config_entry.entry_id)
return stt_config_entry
@pytest.fixture @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.""" """Initialize Wyoming TTS."""
with patch( with patch(
"homeassistant.components.wyoming.data.load_wyoming_info", "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) await hass.config_entries.async_setup(tts_config_entry.entry_id)
return tts_config_entry
@pytest.fixture @pytest.fixture
async def init_wyoming_wake_word( async def init_wyoming_wake_word(
hass: HomeAssistant, wake_word_config_entry: ConfigEntry hass: HomeAssistant, wake_word_config_entry: ConfigEntry
): ) -> ConfigEntry:
"""Initialize Wyoming Wake Word.""" """Initialize Wyoming Wake Word."""
with patch( with patch(
"homeassistant.components.wyoming.data.load_wyoming_info", "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) await hass.config_entries.async_setup(wake_word_config_entry.entry_id)
return wake_word_config_entry
@pytest.fixture @pytest.fixture
async def init_wyoming_intent( async def init_wyoming_intent(

View File

@@ -0,0 +1,58 @@
"""Websocket tests for Wyoming integration."""
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,
) -> 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"