mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
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:
co-authored by
Claude Opus 5
Copilot Autofix powered by AI
parent
a02dad34f1
commit
61743d1df9
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user