mirror of
https://github.com/home-assistant/core.git
synced 2026-08-28 02:24:46 -05:00
Refresh custom wake words without a restart
The custom wake word inventory is cached for the lifetime of the Home Assistant process (_get_custom_wake_words is a singleton), so models added, updated or removed on disk at runtime (e.g. by HACS) did not take effect until a restart. Add a reload_custom_wake_words service that drops the cached inventory, re-scans the directory once, and dispatches a signal. Satellites that negotiate configuration subscribe to the signal and re-push their config so new models become available and removed models disappear immediately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cfb0dc1563
commit
72e2157610
@@ -39,8 +39,12 @@ from homeassistant.components.intent import (
|
||||
)
|
||||
from homeassistant.components.media_player import async_process_play_media_url
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.network import get_url
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
@@ -127,6 +131,12 @@ _DATA_WAKE_WORDS: HassKey[dict[str, VoiceAssistantExternalWakeWord]] = HassKey(
|
||||
"wake_word_cache"
|
||||
)
|
||||
|
||||
SERVICE_RELOAD_CUSTOM_WAKE_WORDS = "reload_custom_wake_words"
|
||||
|
||||
# Dispatched after the custom wake word inventory changes on disk so that
|
||||
# satellites re-push their configuration without a restart.
|
||||
_SIGNAL_WAKE_WORDS_CHANGED = "esphome_custom_wake_words_changed"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -259,6 +269,10 @@ class EsphomeAssistSatellite(
|
||||
# Inform listeners that config has been updated
|
||||
self._entry_data.async_assist_satellite_config_updated(self._satellite_config)
|
||||
|
||||
async def _handle_custom_wake_words_changed(self) -> None:
|
||||
"""Re-push satellite config when the custom wake word inventory changes."""
|
||||
await self._update_satellite_config()
|
||||
|
||||
@override
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
@@ -313,6 +327,17 @@ class EsphomeAssistSatellite(
|
||||
_LOGGER.debug("Waiting for satellite configuration")
|
||||
await self._update_satellite_config()
|
||||
|
||||
# Re-push configuration when the custom wake word inventory changes
|
||||
# (e.g. HACS installs, updates or removes a model) so new models
|
||||
# become available without restarting Home Assistant.
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
_SIGNAL_WAKE_WORDS_CHANGED,
|
||||
self._handle_custom_wake_words_changed,
|
||||
)
|
||||
)
|
||||
|
||||
if not (feature_flags & VoiceAssistantFeature.SPEAKER):
|
||||
# Will use media player for TTS/announcements
|
||||
self._update_tts_format()
|
||||
@@ -970,3 +995,18 @@ async def async_setup(hass: HomeAssistant) -> None:
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def _async_reload_custom_wake_words(call: ServiceCall) -> None:
|
||||
"""Invalidate the cached inventory and refresh satellites."""
|
||||
# The inventory is cached for the lifetime of the process, so drop it
|
||||
# and re-warm it once here (rather than in every satellite) so that a
|
||||
# fan-out of refreshes shares a single directory scan.
|
||||
hass.data.pop(_DATA_WAKE_WORDS, None)
|
||||
await async_get_custom_wake_words(hass)
|
||||
async_dispatcher_send(hass, _SIGNAL_WAKE_WORDS_CHANGED)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RELOAD_CUSTOM_WAKE_WORDS,
|
||||
_async_reload_custom_wake_words,
|
||||
)
|
||||
|
||||
@@ -22,5 +22,10 @@
|
||||
"default": "mdi:microphone"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reload_custom_wake_words": {
|
||||
"service": "mdi:microphone-plus"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
# Empty file, ESPHome services are dynamically created (user-defined services)
|
||||
# Most ESPHome services are dynamically created (user-defined services)
|
||||
|
||||
reload_custom_wake_words:
|
||||
|
||||
@@ -228,5 +228,11 @@
|
||||
"passive": "Passive (lowest device battery use, some details may be missing)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reload_custom_wake_words": {
|
||||
"description": "Rescans the custom wake words directory and updates satellites with the available models.",
|
||||
"name": "Reload custom wake words"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,12 @@ from homeassistant.components.assist_satellite import (
|
||||
|
||||
# pylint: disable-next=home-assistant-component-root-import
|
||||
from homeassistant.components.assist_satellite.entity import AssistSatelliteState
|
||||
from homeassistant.components.esphome.assist_satellite import VoiceAssistantUDPServer
|
||||
from homeassistant.components.esphome.const import NO_WAKE_WORD
|
||||
from homeassistant.components.esphome.assist_satellite import (
|
||||
_DATA_WAKE_WORDS,
|
||||
SERVICE_RELOAD_CUSTOM_WAKE_WORDS,
|
||||
VoiceAssistantUDPServer,
|
||||
)
|
||||
from homeassistant.components.esphome.const import DOMAIN, NO_WAKE_WORD
|
||||
from homeassistant.components.select import (
|
||||
DOMAIN as SELECT_DOMAIN,
|
||||
SERVICE_SELECT_OPTION,
|
||||
@@ -2296,6 +2300,56 @@ async def test_custom_wake_words(
|
||||
assert req.status == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
async def test_reload_custom_wake_words_service(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test the reload service invalidates the cache and refreshes satellites."""
|
||||
expected_config = AssistSatelliteConfiguration(
|
||||
available_wake_words=[
|
||||
AssistSatelliteWakeWord("1234", "okay nabu", ["en"]),
|
||||
],
|
||||
active_wake_words=["1234"],
|
||||
max_active_wake_words=1,
|
||||
)
|
||||
gvac = mock_client.get_voice_assistant_configuration
|
||||
gvac.return_value = expected_config
|
||||
|
||||
mock_device = await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info={
|
||||
"voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT
|
||||
| VoiceAssistantFeature.ANNOUNCE
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
satellite = get_satellite_entity(hass, mock_device.device_info.mac_address)
|
||||
assert satellite is not None
|
||||
|
||||
# Config was pushed once at setup, populating the inventory cache.
|
||||
gvac.assert_called_once()
|
||||
assert _DATA_WAKE_WORDS in hass.data
|
||||
|
||||
# Poison the cache so the service must re-scan disk to recover.
|
||||
hass.data[_DATA_WAKE_WORDS] = {}
|
||||
gvac.reset_mock()
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN, SERVICE_RELOAD_CUSTOM_WAKE_WORDS, {}, blocking=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The satellite re-pushed its config using the freshly re-scanned models.
|
||||
gvac.assert_called_once()
|
||||
external_wake_words = gvac.call_args_list[0].kwargs["external_wake_words"]
|
||||
assert {eww.id for eww in external_wake_words} == {
|
||||
"hey_home_assistant",
|
||||
"choo_choo_homie/choo_choo_homie",
|
||||
}
|
||||
|
||||
|
||||
async def test_multichannel_audio(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
|
||||
Reference in New Issue
Block a user