From 72e2157610cb8aff4a2dd58ded37c40a1dcaebb2 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 30 Jul 2026 16:43:48 -0500 Subject: [PATCH] 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 --- .../components/esphome/assist_satellite.py | 42 +++++++++++++- homeassistant/components/esphome/icons.json | 5 ++ .../components/esphome/services.yaml | 4 +- homeassistant/components/esphome/strings.json | 6 ++ .../esphome/test_assist_satellite.py | 58 ++++++++++++++++++- 5 files changed, 111 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 57591395b8db..59ae3254749b 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -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, + ) diff --git a/homeassistant/components/esphome/icons.json b/homeassistant/components/esphome/icons.json index f4ac1872f5f8..2fab9abcf092 100644 --- a/homeassistant/components/esphome/icons.json +++ b/homeassistant/components/esphome/icons.json @@ -22,5 +22,10 @@ "default": "mdi:microphone" } } + }, + "services": { + "reload_custom_wake_words": { + "service": "mdi:microphone-plus" + } } } diff --git a/homeassistant/components/esphome/services.yaml b/homeassistant/components/esphome/services.yaml index f4c31420f9a8..5556ed69e4b8 100644 --- a/homeassistant/components/esphome/services.yaml +++ b/homeassistant/components/esphome/services.yaml @@ -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: diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 0d0c6f6dbb48..7f0cb6b4ac3f 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -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" + } } } diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index c57c5df87596..6a095d395903 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -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,