Skip to content

Add wake_word category for custom wake word models - #5435

Draft
synesthesiam wants to merge 4 commits into
hacs:mainfrom
synesthesiam:add-wake-word-category
Draft

Add wake_word category for custom wake word models#5435
synesthesiam wants to merge 4 commits into
hacs:mainfrom
synesthesiam:add-wake-word-category

Conversation

@synesthesiam

@synesthesiam synesthesiam commented Jul 30, 2026

Copy link
Copy Markdown

What

Adds a new HACS category wake_word that downloads custom wake word models into Home Assistant's config/custom_wake_words/ directory — the directory scanned by the esphome integration (WAKE_WORDS_DIR_NAME).

Relates to OpenHomeFoundation/roadmap#180

Important

Depends on home-assistant/core#177676.
That PR adds the esphome.reload_custom_wake_words service this PR calls from its
post-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_words must change with it.

Repository layout

A wake_word repository ships a config manifest and a .tflite model that share the same stem, inside a custom_wake_words/ directory:

my-wake-word-repo/
├── hacs.json
├── README.md
├── LICENSE
└── custom_wake_words/
    ├── my_wake_word.json      # {"type": ..., "wake_word": ..., "model": "my_wake_word.tflite", ...}
    └── my_wake_word.tflite

HACS copies the contents of custom_wake_words/ into config/custom_wake_words/<owner>/<repo>/, where Home Assistant's rglob("*.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 to custom_wake_words/, those ids stay unique too.

Changes

  • HacsCategory.WAKE_WORD enum value.
  • HacsWakeWordRepository — installs into config/custom_wake_words/<owner>/<repo>/ (directory install, not single-file).
  • Registered in REPOSITORY_CLASSES and added to the _V2_REPO_SCHEMAS validation map.
  • Category is enabled when the esphome integration is loaded or a wake_word repository is already downloaded — mirroring how python_script is gated, so it isn't fetched for users without the voice stack.
  • Action validator (wake_word_model) that enforces a stricter shape than HA's intentionally permissive drag-and-drop loader: exactly one manifest and one .tflite model sharing a stem, required config keys (type, wake_word, model), and the manifest's model value 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, model mismatch.
  • 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.
  • Empty wake_word/data.json proxy fixture.

Open questions for reviewers

  • Gating on esphome: the loader currently lives in the esphome integration. If custom wake words become a more general voice feature, the enable condition should broaden. Happy to adjust.
  • One model per repo: the validator enforces a single manifest+model pair. Allowing multiple models via subdirectories would be a deliberate follow-up.

🤖 Generated with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_WORD and registers HacsWakeWordRepository.
  • Adds category gating so wake_word becomes active when esphome is loaded (or when a wake_word repo is already downloaded).
  • Adds a wake_word_model validator 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.

Comment on lines +37 to +47
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
]

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +43 to +48
compliant = False
for treefile in self.treefiles:
if treefile.startswith(self.content.path.remote) and treefile.endswith(".tflite"):
compliant = True
break
if not compliant:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tests/hacsbase/test_hacs.py Outdated
Comment on lines +36 to +45
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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
synesthesiam and others added 2 commits July 30, 2026 16:34
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants