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:
Michael Hansen
2026-07-30 16:43:48 -05:00
co-authored by Claude Opus 4.8
parent cfb0dc1563
commit 72e2157610
5 changed files with 111 additions and 4 deletions
@@ -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,