Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 58 additions & 9 deletions homeassistant/components/esphome/assist_satellite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -119,13 +123,20 @@
{
vol.Required("type"): str,
vol.Required("wake_word"): str,
vol.Required("model"): str,
},
extra=vol.ALLOW_EXTRA,
)
_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,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -904,26 +930,33 @@ 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,
humanize_error(config_dict, err),
)
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()

Expand All @@ -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,
Expand All @@ -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,
)
5 changes: 5 additions & 0 deletions homeassistant/components/esphome/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,10 @@
"default": "mdi:microphone"
}
}
},
"services": {
"reload_custom_wake_words": {
"service": "mdi:microphone-plus"
}
}
}
4 changes: 3 additions & 1 deletion homeassistant/components/esphome/services.yaml
Original file line number Diff line number Diff line change
@@ -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:
6 changes: 6 additions & 0 deletions homeassistant/components/esphome/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
62 changes: 58 additions & 4 deletions tests/components/esphome/test_assist_satellite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading