Refresh Wyoming service info periodically (#179086)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Michael Hansen
2026-08-14 14:31:04 +02:00
committed by GitHub
co-authored by Claude Opus 5 Copilot Autofix powered by AI
parent a02dad34f1
commit 61743d1df9
8 changed files with 353 additions and 43 deletions
+16 -2
View File
@@ -3,12 +3,13 @@
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
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 .coordinator import WyomingInfoCoordinator
from .data import WyomingService
from .devices import SatelliteDevice
from .models import DomainDataItem, WyomingConfigEntry
@@ -46,7 +47,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: WyomingConfigEntry) -> b
if service is None:
raise ConfigEntryNotReady("Unable to connect")
item = DomainDataItem(service=service)
coordinator = WyomingInfoCoordinator(
hass, entry, entry.data["host"], entry.data["port"]
)
@callback
def _async_update_service_info() -> None:
service.info = coordinator.data
# The coordinator only schedules refreshes while it has listeners, so this
# also keeps it polling for platforms that read info without listening.
entry.async_on_unload(coordinator.async_add_listener(_async_update_service_info))
coordinator.async_set_updated_data(service.info)
item = DomainDataItem(service=service, coordinator=coordinator)
entry.runtime_data = item
await hass.config_entries.async_forward_entry_setups(entry, service.platforms)
@@ -0,0 +1,50 @@
"""Coordinator for refreshing Wyoming service info."""
from datetime import timedelta
import logging
from typing import override
from wyoming.info import Info
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .data import load_wyoming_info
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=30)
class WyomingInfoCoordinator(DataUpdateCoordinator[Info]):
"""Periodically refresh info from a Wyoming service.
Services can gain or lose voices, wake word models, etc. while Home
Assistant is running, so the info collected during setup goes stale.
"""
def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, host: str, port: int
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{host}:{port}",
update_interval=UPDATE_INTERVAL,
always_update=False,
)
self.host = host
self.port = port
@override
async def _async_update_data(self) -> Info:
"""Fetch info from the Wyoming service."""
# A single attempt is enough because the next interval retries anyway.
info = await load_wyoming_info(self.host, self.port, retries=0)
if info is None:
raise UpdateFailed(f"Unable to get info from {self.host}:{self.port}")
return info
@@ -4,6 +4,7 @@ from dataclasses import dataclass
from homeassistant.config_entries import ConfigEntry
from .coordinator import WyomingInfoCoordinator
from .data import WyomingService
from .devices import SatelliteDevice
@@ -13,6 +14,7 @@ class DomainDataItem:
"""Domain data item."""
service: WyomingService
coordinator: WyomingInfoCoordinator
device: SatelliteDevice | None = None
+49 -14
View File
@@ -10,6 +10,7 @@ import wave
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.client import AsyncTcpClient
from wyoming.error import Error
from wyoming.info import TtsProgram
from wyoming.tts import (
Synthesize,
SynthesizeChunk,
@@ -25,6 +26,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_SPEAKER
from .coordinator import WyomingInfoCoordinator
from .data import WyomingService
from .error import WyomingError, error_event_message
from .models import WyomingConfigEntry
@@ -41,7 +43,7 @@ async def async_setup_entry(
item = config_entry.runtime_data
async_add_entities(
[
WyomingTtsProvider(config_entry, item.service),
WyomingTtsProvider(config_entry, item.coordinator, item.service),
]
)
@@ -55,22 +57,55 @@ class WyomingTtsProvider(tts.TextToSpeechEntity):
def __init__(
self,
config_entry: WyomingConfigEntry,
coordinator: WyomingInfoCoordinator,
service: WyomingService,
) -> None:
"""Set up provider."""
self.config_entry = config_entry
self.coordinator = coordinator
self.service = service
self._tts_service = next(tts for tts in service.info.tts if tts.installed)
# The platform is only set up when an installed TTS service exists.
self._tts_service = next(tts for tts in service.info.tts if tts.installed)
self._voices: dict[str, list[tts.Voice]] = {}
self._rebuild_voices(self._tts_service)
self._attr_name = self._tts_service.name
self._attr_unique_id = f"{config_entry.entry_id}-tts" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@override
async def async_added_to_hass(self) -> None:
"""Subscribe to info updates."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.async_add_listener(self._handle_info_update)
)
@callback
def _handle_info_update(self) -> None:
"""Rebuild the voice list when the service reports new info."""
tts_service = next(
(tts for tts in self.coordinator.data.tts if tts.installed), None
)
if tts_service is None:
# Keep the last known voices if the service reports none.
return
self._tts_service = tts_service
self._rebuild_voices(tts_service)
@callback
def _rebuild_voices(self, tts_service: TtsProgram) -> None:
"""Collect the installed voices, grouped by language."""
voice_languages: set[str] = set()
self._voices: dict[str, list[tts.Voice]] = defaultdict(list)
for voice in self._tts_service.voices:
voices: dict[str, list[tts.Voice]] = defaultdict(list)
for voice in tts_service.voices:
if not voice.installed:
continue
voice_languages.update(voice.languages)
for language in voice.languages:
self._voices[language].append(
voices[language].append(
tts.Voice(
voice_id=voice.name,
name=voice.description or voice.name,
@@ -78,18 +113,18 @@ class WyomingTtsProvider(tts.TextToSpeechEntity):
)
# Sort voices by name
for language in self._voices:
self._voices[language] = sorted(
self._voices[language], key=lambda v: v.name
)
for language_voices in voices.values():
language_voices.sort(key=lambda v: v.name)
self._attr_supported_languages = list(voice_languages)
if self._attr_supported_languages:
self._voices = voices
self._attr_supported_languages = sorted(voice_languages)
# Only move the default when the current one is gone so that installing
# an unrelated voice cannot change it.
current_default = getattr(self, "_attr_default_language", None)
if voice_languages and current_default not in voice_languages:
self._attr_default_language = self._attr_supported_languages[0]
self._attr_name = self._tts_service.name
self._attr_unique_id = f"{config_entry.entry_id}-tts" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@callback
@override
def async_get_supported_voices(self, language: str) -> list[tts.Voice] | None:
+38 -21
View File
@@ -8,13 +8,15 @@ from typing import override
from wyoming.audio import AudioChunk, AudioStart
from wyoming.client import AsyncTcpClient
from wyoming.error import Error
from wyoming.info import WakeProgram
from wyoming.wake import Detect, Detection
from homeassistant.components import wake_word
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .data import WyomingService, load_wyoming_info
from .coordinator import WyomingInfoCoordinator
from .data import WyomingService
from .error import WyomingError, error_event_message
from .models import WyomingConfigEntry
@@ -30,7 +32,7 @@ async def async_setup_entry(
item = config_entry.runtime_data
async_add_entities(
[
WyomingWakeWordProvider(hass, config_entry, item.service),
WyomingWakeWordProvider(hass, config_entry, item.coordinator, item.service),
]
)
@@ -42,40 +44,55 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity):
self,
hass: HomeAssistant,
config_entry: WyomingConfigEntry,
coordinator: WyomingInfoCoordinator,
service: WyomingService,
) -> None:
"""Set up provider."""
self.hass = hass
self.coordinator = coordinator
self.service = service
wake_service = service.info.wake[0]
# The platform is only set up when an installed wake service exists.
wake_service = next(wake for wake in service.info.wake if wake.installed)
self._supported_wake_words: list[wake_word.WakeWord] = []
self._rebuild_wake_words(wake_service)
self._attr_name = wake_service.name
self._attr_unique_id = f"{config_entry.entry_id}-wake_word" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@override
async def async_added_to_hass(self) -> None:
"""Subscribe to info updates."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.async_add_listener(self._handle_info_update)
)
@callback
def _handle_info_update(self) -> None:
"""Rebuild the wake word list when the service reports new info."""
wake_service = next(
(wake for wake in self.coordinator.data.wake if wake.installed), None
)
if wake_service is None:
# Keep the last known wake words if the service reports none.
return
self._rebuild_wake_words(wake_service)
@callback
def _rebuild_wake_words(self, wake_service: WakeProgram) -> None:
"""Collect the wake words offered by the service."""
self._supported_wake_words = [
wake_word.WakeWord(
id=ww.name, name=ww.description or ww.name, phrase=ww.phrase
)
for ww in wake_service.models
]
self._attr_name = wake_service.name
self._attr_unique_id = f"{config_entry.entry_id}-wake_word" # pylint: disable=home-assistant-entity-unique-id-redundant-platform
@override
async def get_supported_wake_words(self) -> list[wake_word.WakeWord]:
"""Return a list of supported wake words."""
info = await load_wyoming_info(
self.service.host, self.service.port, retries=0, timeout=1
)
if info is not None:
wake_service = info.wake[0]
self._supported_wake_words = [
wake_word.WakeWord(
id=ww.name,
name=ww.description or ww.name,
phrase=ww.phrase,
)
for ww in wake_service.models
]
return self._supported_wake_words
@override
+53
View File
@@ -69,6 +69,59 @@ TTS_INFO = Info(
)
]
)
TTS_INFO_NEW_VOICE = Info(
tts=[
TtsProgram(
name="Test TTS",
description="Test TTS",
installed=True,
attribution=TEST_ATTR,
voices=[
TtsVoice(
name="Test Voice",
description="Test Voice",
installed=True,
attribution=TEST_ATTR,
languages=["en-US"],
speakers=[TtsVoiceSpeaker(name="Test Speaker")],
version=None,
),
TtsVoice(
name="New Voice",
description="New Voice",
installed=True,
attribution=TEST_ATTR,
languages=["de-DE"],
speakers=None,
version=None,
),
],
version=None,
)
]
)
TTS_INFO_LANGUAGE_REPLACED = Info(
tts=[
TtsProgram(
name="Test TTS",
description="Test TTS",
installed=True,
attribution=TEST_ATTR,
voices=[
TtsVoice(
name="New Voice",
description="New Voice",
installed=True,
attribution=TEST_ATTR,
languages=["de-DE"],
speakers=None,
version=None,
)
],
version=None,
)
]
)
TTS_STREAMING_INFO = Info(
tts=[
TtsProgram(
+72 -1
View File
@@ -12,11 +12,16 @@ from wyoming.error import Error
from wyoming.tts import SynthesizeStopped
from homeassistant.components import tts, wyoming
from homeassistant.components.wyoming.coordinator import UPDATE_INTERVAL
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_component import DATA_INSTANCES
from homeassistant.util import dt as dt_util
from . import MockAsyncTcpClient
from . import TTS_INFO_LANGUAGE_REPLACED, TTS_INFO_NEW_VOICE, MockAsyncTcpClient
from tests.common import async_fire_time_changed
async def test_support(hass: HomeAssistant, init_wyoming_tts) -> None:
@@ -40,6 +45,72 @@ async def test_support(hass: HomeAssistant, init_wyoming_tts) -> None:
assert not entity.async_get_supported_voices("de-DE")
async def test_voices_refreshed(
hass: HomeAssistant, init_wyoming_tts: ConfigEntry
) -> None:
"""Test that a voice added on the service becomes available."""
entity = hass.data[DATA_INSTANCES]["tts"].get_entity("tts.test_tts")
assert entity is not None
assert entity.async_get_supported_voices("de-DE") is None
with patch(
"homeassistant.components.wyoming.coordinator.load_wyoming_info",
return_value=TTS_INFO_NEW_VOICE,
):
async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL)
await hass.async_block_till_done()
assert set(entity.supported_languages) == {"en-US", "de-DE"}
voices = entity.async_get_supported_voices("de-DE")
assert voices is not None
assert [voice.voice_id for voice in voices] == ["New Voice"]
# Adding a language must not move the default away from a supported one.
assert entity.default_language == "en-US"
# Refreshed info is shared with the rest of the integration.
assert init_wyoming_tts.runtime_data.service.info == TTS_INFO_NEW_VOICE
@pytest.mark.usefixtures("init_wyoming_tts")
async def test_default_language_follows_removed_language(
hass: HomeAssistant,
) -> None:
"""Test that the default language moves when the current one is removed."""
entity = hass.data[DATA_INSTANCES]["tts"].get_entity("tts.test_tts")
assert entity is not None
assert entity.default_language == "en-US"
with patch(
"homeassistant.components.wyoming.coordinator.load_wyoming_info",
return_value=TTS_INFO_LANGUAGE_REPLACED,
):
async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL)
await hass.async_block_till_done()
assert entity.supported_languages == ["de-DE"]
assert entity.default_language == "de-DE"
@pytest.mark.usefixtures("init_wyoming_tts")
async def test_voices_kept_when_refresh_fails(hass: HomeAssistant) -> None:
"""Test that the last known voices are kept when a refresh fails."""
entity = hass.data[DATA_INSTANCES]["tts"].get_entity("tts.test_tts")
assert entity is not None
with patch(
"homeassistant.components.wyoming.coordinator.load_wyoming_info",
return_value=None,
):
async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL)
await hass.async_block_till_done()
assert entity.supported_languages == ["en-US"]
voices = entity.async_get_supported_voices("en-US")
assert voices is not None
assert [voice.voice_id for voice in voices] == ["Test Voice"]
async def test_get_tts_audio(
hass: HomeAssistant, init_wyoming_tts, snapshot: SnapshotAssertion
) -> None:
+73 -5
View File
@@ -11,10 +11,14 @@ from wyoming.info import Info, WakeModel, WakeProgram
from wyoming.wake import Detection
from homeassistant.components import wake_word
from homeassistant.components.wyoming.coordinator import UPDATE_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import TEST_ATTR, MockAsyncTcpClient
from tests.common import async_fire_time_changed
async def test_support(hass: HomeAssistant, init_wyoming_wake_word) -> None:
"""Test supported properties."""
@@ -251,12 +255,76 @@ async def test_dynamic_wake_word_info(
]
)
# Different Wyoming info will be fetched
# Different Wyoming info will be fetched on the next refresh
with patch(
"homeassistant.components.wyoming.wake_word.load_wyoming_info",
"homeassistant.components.wyoming.coordinator.load_wyoming_info",
return_value=new_info,
):
assert (await entity.get_supported_wake_words()) == [
wake_word.WakeWord("ww1", "Wake Word 1", "Wake Word Phrase 1"),
wake_word.WakeWord("ww2", "Wake Word 2", "Wake Word Phrase 2"),
async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL)
await hass.async_block_till_done()
assert (await entity.get_supported_wake_words()) == [
wake_word.WakeWord("ww1", "Wake Word 1", "Wake Word Phrase 1"),
wake_word.WakeWord("ww2", "Wake Word 2", "Wake Word Phrase 2"),
]
@pytest.mark.usefixtures("init_wyoming_wake_word")
async def test_uninstalled_wake_service_skipped(hass: HomeAssistant) -> None:
"""Test that wake words are taken from an installed program."""
entity = wake_word.async_get_wake_word_detection_entity(
hass, "wake_word.test_wake_word"
)
assert entity is not None
new_info = Info(
wake=[
WakeProgram(
name="not-installed",
description="Not Installed",
installed=False,
attribution=TEST_ATTR,
models=[
WakeModel(
name="unavailable",
description="Unavailable",
phrase="Unavailable Phrase",
installed=False,
attribution=TEST_ATTR,
languages=[],
version=None,
)
],
version=None,
),
WakeProgram(
name="installed",
description="Installed",
installed=True,
attribution=TEST_ATTR,
models=[
WakeModel(
name="available",
description="Available",
phrase="Available Phrase",
installed=True,
attribution=TEST_ATTR,
languages=[],
version=None,
)
],
version=None,
),
]
)
with patch(
"homeassistant.components.wyoming.coordinator.load_wyoming_info",
return_value=new_info,
):
async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL)
await hass.async_block_till_done()
assert (await entity.get_supported_wake_words()) == [
wake_word.WakeWord("available", "Available", "Available Phrase")
]