Add wake_word category for custom wake word models - #5435
Conversation
Add a new HACS category `wake_word` that downloads custom wake word
models into Home Assistant's `config/custom_wake_words/<repo-name>/`
directory (the directory read by the esphome integration).
A repository ships a config manifest and a `.tflite` model that share
the same stem, e.g.:
custom_wake_words/
├── my_wake_word.json ({"type", "wake_word", "model": "my_wake_word.tflite", ...})
└── my_wake_word.tflite
The category is enabled when the esphome integration is loaded or a
wake_word repository has already been downloaded, mirroring how the
python_script category is gated.
An action validator enforces a stricter shape than Home Assistant's
permissive drag-and-drop loader: exactly one manifest and one model
sharing a stem, and the manifest's "model" value naming that file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new HACS repository category for distributing custom wake word models in the Home Assistant custom_wake_words/ directory, including category gating and validation support.
Changes:
- Introduces
HacsCategory.WAKE_WORDand registersHacsWakeWordRepository. - Adds category gating so
wake_wordbecomes active whenesphomeis loaded (or when a wake_word repo is already downloaded). - Adds a
wake_word_modelvalidator and test coverage for the expected repo structure.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
custom_components/hacs/enums.py |
Adds the WAKE_WORD category enum value. |
custom_components/hacs/repositories/__init__.py |
Registers HacsWakeWordRepository for the new category. |
custom_components/hacs/repositories/wake_word.py |
Implements repository behavior and install path for wake word model repos. |
custom_components/hacs/base.py |
Enables the category based on esphome being loaded or existing downloaded wake_word repos. |
custom_components/hacs/utils/validate.py |
Adds wake_word to the v2 data.json schema map. |
custom_components/hacs/validate/wake_word_model.py |
Adds the stricter action validator for wake word model repo structure. |
tests/validate/test_wake_word_model_check.py |
Adds validator tests for valid/invalid wake word repositories. |
tests/hacsbase/test_hacs.py |
Adds a test for wake word category gating. |
tests/conftest.py |
Adds a repository_wake_word fixture. |
tests/fixtures/proxy/data-v2.hacs.xyz/wake_word/data.json |
Adds proxy fixture for wake_word category data endpoint. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| content_path = ( | ||
| "" if self.repository.repository_manifest.content_in_root else "custom_wake_words" | ||
| ) | ||
| location = f"'{content_path}/'" if content_path else "the repository root" | ||
|
|
||
| # Files located directly in the content directory (not nested deeper). | ||
| treefiles = [ | ||
| treefile | ||
| for treefile in self.repository.tree | ||
| if not treefile.is_directory and treefile.path == content_path | ||
| ] |
There was a problem hiding this comment.
The premise here isn't accurate: AIOGitHubAPIRepositoryTreeContent.path returns the directory only, not the full path, so it matches LegacyTreeFile.path. Verified against the version pinned in this repo:
full_path='custom_wake_words/my_wake_word.json' path='custom_wake_words' filename='my_wake_word.json'
full_path='custom_wake_words/sub/deep.tflite' path='custom_wake_words/sub' filename='deep.tflite'
full_path='root.json' path='' filename='root.json'
So treefile.path == content_path selects the right files under both tree representations — which is why test_valid_wake_word_repository (and the content_in_root case) pass. There's no missed-file bug.
That said, the optional half of the suggestion was worth doing: the top-level match silently ignored files nested deeper, which could let a second model slip in past the single-pair rule. Fixed in fd44f3c — the validator now explicitly rejects .json/.tflite nested in a subdirectory of the content directory, with a test_nested_wake_word_files_rejected case.
| compliant = False | ||
| for treefile in self.treefiles: | ||
| if treefile.startswith(self.content.path.remote) and treefile.endswith(".tflite"): | ||
| compliant = True | ||
| break | ||
| if not compliant: |
There was a problem hiding this comment.
Good catch — addressed in fd44f3c. validate_repository now requires both a model (.tflite) and a config manifest (a non-hacs.json .json) under the content path, so a model-only repo is no longer considered compliant. This mirrors what Home Assistant actually loads (it discovers wake words by scanning manifests, then loads the model each one names). In content_in_root mode the same check runs against the repository root. Covered by test_validate_repository_requires_manifest_and_model, test_validate_repository_missing_manifest, and test_validate_repository_missing_model.
| async def test_wake_word_category_requires_esphome(hacs): | ||
| """The wake_word category is only active when esphome is loaded.""" | ||
| assert "esphome" not in hacs.hass.config.components | ||
| hacs.set_active_categories() | ||
| assert HacsCategory.WAKE_WORD not in hacs.common.categories | ||
|
|
||
| hacs.hass.config.components.add("esphome") | ||
| hacs.set_active_categories() | ||
| assert HacsCategory.WAKE_WORD in hacs.common.categories | ||
|
|
There was a problem hiding this comment.
Addressed in fd44f3c. Renamed the original test to test_wake_word_category_enabled_by_esphome and fixed its docstring, and added test_wake_word_category_enabled_when_downloaded to cover the category_downloaded branch of the gate (category active without esphome loaded when a wake_word repo is already installed).
Install wake word repositories into config/custom_wake_words/<owner>/<repo>/ instead of config/custom_wake_words/<repo>/ so that two repositories sharing a repo name but with different owners do not collide on disk. Home Assistant derives the wake word id from the path relative to custom_wake_words/, so this also keeps those ids unique. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The wake word inventory is cached for the lifetime of the Home Assistant process, so downloading, updating or removing a wake_word repository had no effect until a restart. Call the esphome reload_custom_wake_words service from the post-installation and post-uninstall hooks (guarded by has_service) so the inventory is refreshed immediately, mirroring how the theme category reloads frontend themes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- validate_repository now requires both a config manifest (.json) and a model (.tflite); a lone model would install but Home Assistant, which discovers wake words by scanning manifests, would never load it. - The action validator rejects wake word files nested in a subdirectory of the content directory, enforcing the single flat manifest+model pair. - Cover the "already downloaded" gate in set_active_categories (not just the esphome-loaded branch) and fix the test docstring. - Add the api-usage snapshots the wake_word tests require at teardown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What
Adds a new HACS category
wake_wordthat downloads custom wake word models into Home Assistant'sconfig/custom_wake_words/directory — the directory scanned by theesphomeintegration (WAKE_WORDS_DIR_NAME).Relates to OpenHomeFoundation/roadmap#180
Important
Depends on home-assistant/core#177676.
That PR adds the
esphome.reload_custom_wake_wordsservice this PR calls from itspost-install/post-uninstall hooks to refresh the wake word inventory without a restart.
It should land (or at least settle the service name) first; if the service name changes
during review, the string in
_reload_custom_wake_wordsmust change with it.Repository layout
A
wake_wordrepository ships a config manifest and a.tflitemodel that share the same stem, inside acustom_wake_words/directory:HACS copies the contents of
custom_wake_words/intoconfig/custom_wake_words/<owner>/<repo>/, where Home Assistant'srglob("*.json")loader picks it up. The install path is namespaced by the full name (<owner>/<repo>) rather than just the repo name, so two repositories sharing a repo name but with different owners do not collide on disk — and since HA derives the wake word id from the path relative tocustom_wake_words/, those ids stay unique too.Changes
HacsCategory.WAKE_WORDenum value.HacsWakeWordRepository— installs intoconfig/custom_wake_words/<owner>/<repo>/(directory install, not single-file).REPOSITORY_CLASSESand added to the_V2_REPO_SCHEMASvalidation map.esphomeintegration is loaded or awake_wordrepository is already downloaded — mirroring howpython_scriptis gated, so it isn't fetched for users without the voice stack.wake_word_model) that enforces a stricter shape than HA's intentionally permissive drag-and-drop loader: exactly one manifest and one.tflitemodel sharing a stem, required config keys (type,wake_word,model), and the manifest'smodelvalue naming that model file exactly.Tests
tests/validate/test_wake_word_model_check.py— valid case,content_in_root, missing/duplicate manifest, missing/duplicate model, stem mismatch, invalid JSON, missing key,modelmismatch.tests/repositories/test_wake_word_repository.py— install path is namespaced by full name and deconflicts same-named repos from different owners.tests/hacsbase/test_hacs.py::test_wake_word_category_requires_esphome— category gating.wake_word/data.jsonproxy fixture.Open questions for reviewers
esphome: the loader currently lives in theesphomeintegration. If custom wake words become a more general voice feature, the enable condition should broaden. Happy to adjust.🤖 Generated with Claude Code