diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 0182cacf9cfb70..59ae3254749b29 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -39,8 +39,12 @@ ) 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 @@ -119,6 +123,7 @@ { vol.Required("type"): str, vol.Required("wake_word"): str, + vol.Required("model"): str, }, extra=vol.ALLOW_EXTRA, ) @@ -126,6 +131,12 @@ "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, @@ -258,6 +269,10 @@ async def _update_satellite_config(self) -> None: # 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.""" @@ -312,6 +327,17 @@ async def async_added_to_hass(self) -> None: _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() @@ -904,19 +930,17 @@ def _get_custom_wake_words( wake_words: dict[str, VoiceAssistantExternalWakeWord] = {} # Look for config/model files - for config_path in wake_words_dir.glob("*.json"): - wake_word_id = config_path.stem - model_path = config_path.with_suffix(".tflite") - if not model_path.exists(): - # Missing model file - continue + for config_path in wake_words_dir.rglob("*.json"): + # Use relative path to deconflict ids: + # custom_wake_words/my_wake_word.json -> my_wake_word + # custom_wake_words/sub_dir/my_wake_word.json -> sub_dir/my_wake_word + wake_word_id = str(config_path.relative_to(wake_words_dir).with_suffix("")) with open(config_path, encoding="utf-8") as config_file: config_dict = json.load(config_file) try: config = _WAKE_WORD_CONFIG_SCHEMA(config_dict) except vol.Invalid as err: - # Invalid config _LOGGER.debug( "Invalid wake word config: path=%s, error=%s", config_path, @@ -924,6 +948,15 @@ def _get_custom_wake_words( ) continue + model_path = config_path.parent / config["model"] + if not model_path.exists(): + _LOGGER.debug( + "Missing custom wake word model file: %s (config=%s)", + model_path, + config_path, + ) + continue + with open(model_path, "rb") as model_file: model_hash = hashlib.sha256(model_file.read()).hexdigest() @@ -933,10 +966,11 @@ def _get_custom_wake_words( # Only intended for the internal network base_url = get_url(hass, prefer_external=False, allow_cloud=False) + wake_word = config["wake_word"] wake_words[wake_word_id] = VoiceAssistantExternalWakeWord.from_dict( { "id": wake_word_id, - "wake_word": config["wake_word"], + "wake_word": wake_word, "trained_languages": config_dict.get("trained_languages", []), "model_type": config["type"], "model_size": model_size, @@ -961,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 f4ac1872f5f899..2fab9abcf0923c 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 f4c31420f9a89f..5556ed69e4b83c 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 0d0c6f6dbb4838..7f0cb6b4ac3f1e 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 c83fc9d55b3b2e..6a095d395903af 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -41,8 +41,12 @@ # 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, @@ -2229,7 +2233,7 @@ async def test_custom_wake_words( Expects 2 models in testing_config/custom_wake_words: - hey_home_assistant - - choo_choo_homie + - choo_choo_homie (in choo_choo_homie sub-directory) """ http_client = await hass_client() expected_config = AssistSatelliteConfiguration( @@ -2261,7 +2265,7 @@ async def test_custom_wake_words( assert {external_wake_words[0].id, external_wake_words[1].id} == { "hey_home_assistant", - "choo_choo_homie", + "choo_choo_homie/choo_choo_homie", } # Verify details @@ -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, diff --git a/tests/testing_config/custom_wake_words/choo_choo_homie.json b/tests/testing_config/custom_wake_words/choo_choo_homie/choo_choo_homie.json similarity index 100% rename from tests/testing_config/custom_wake_words/choo_choo_homie.json rename to tests/testing_config/custom_wake_words/choo_choo_homie/choo_choo_homie.json diff --git a/tests/testing_config/custom_wake_words/choo_choo_homie.tflite b/tests/testing_config/custom_wake_words/choo_choo_homie/choo_choo_homie.tflite similarity index 100% rename from tests/testing_config/custom_wake_words/choo_choo_homie.tflite rename to tests/testing_config/custom_wake_words/choo_choo_homie/choo_choo_homie.tflite