-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Add wake_word category for custom wake word models #5435
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
8f635a4
c9b7e55
4ee8f73
fd44f3c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| """Class for wake word models in HACS.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import TYPE_CHECKING | ||
|
|
||
| from ..enums import HacsCategory, HacsDispatchEvent | ||
| from ..exceptions import HacsException | ||
| from ..utils.decorator import concurrent | ||
| from .base import HacsRepository | ||
|
|
||
| if TYPE_CHECKING: | ||
| from ..base import HacsBase | ||
|
|
||
|
|
||
| class HacsWakeWordRepository(HacsRepository): | ||
| """Wake word models in HACS.""" | ||
|
|
||
| def __init__(self, hacs: HacsBase, full_name: str): | ||
| """Initialize.""" | ||
| super().__init__(hacs=hacs) | ||
| self.data.full_name = full_name | ||
| self.data.full_name_lower = full_name.lower() | ||
| self.data.category = HacsCategory.WAKE_WORD | ||
| self.content.path.remote = "custom_wake_words" | ||
| self.content.path.local = self.localpath | ||
| self.content.single = False | ||
|
|
||
| @property | ||
| def localpath(self): | ||
| """Return localpath.""" | ||
| return f"{self.hacs.core.config_path}/custom_wake_words/{self.data.name}" | ||
|
|
||
| async def validate_repository(self): | ||
| """Validate.""" | ||
| # Run common validation steps. | ||
| await self.common_validate() | ||
|
|
||
| # Custom step 1: Validate content. | ||
| if self.repository_manifest.content_in_root: | ||
| self.content.path.remote = "" | ||
|
|
||
| compliant = False | ||
| for treefile in self.treefiles: | ||
| if treefile.startswith(self.content.path.remote) and treefile.endswith(".tflite"): | ||
| compliant = True | ||
| break | ||
| if not compliant: | ||
| raise HacsException( | ||
| f"{self.string} Repository structure for {self.ref.replace('tags/', '')} " | ||
| "is not compliant" | ||
| ) | ||
|
|
||
| # Handle potential errors | ||
| if self.validate.errors: | ||
| for error in self.validate.errors: | ||
| if not self.hacs.status.startup: | ||
| self.logger.error("%s %s", self.string, error) | ||
| return self.validate.success | ||
|
|
||
| @concurrent(concurrenttasks=10, backoff_time=5) | ||
| async def update_repository(self, ignore_issues=False, force=False): | ||
| """Update.""" | ||
| if not await self.common_update(ignore_issues, force) and not force: | ||
| return | ||
|
|
||
| # Get wake word model objects. | ||
| if self.repository_manifest.content_in_root: | ||
| self.content.path.remote = "" | ||
|
|
||
| # Set local path | ||
| self.content.path.local = self.localpath | ||
|
|
||
| # Signal frontend to refresh | ||
| if self.data.installed: | ||
| self.hacs.async_dispatch( | ||
| HacsDispatchEvent.REPOSITORY, | ||
| { | ||
| "id": 1337, | ||
| "action": "update", | ||
| "repository": self.data.full_name, | ||
| "repository_id": self.data.id, | ||
| }, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from typing import TYPE_CHECKING | ||
|
|
||
| from ..enums import HacsCategory, RepositoryFile | ||
| from ..utils.json import json_loads | ||
| from .base import ActionValidationBase, ValidationException | ||
|
|
||
| if TYPE_CHECKING: | ||
| from ..repositories.base import HacsRepository | ||
| from ..repositories.wake_word import HacsWakeWordRepository | ||
|
|
||
|
|
||
| async def async_setup_validator(repository: HacsRepository) -> Validator: | ||
| """Set up this validator.""" | ||
| return Validator(repository=repository) | ||
|
|
||
|
|
||
| class Validator(ActionValidationBase): | ||
| """Validate the wake word model repository.""" | ||
|
|
||
| repository: HacsWakeWordRepository | ||
|
|
||
| categories = (HacsCategory.WAKE_WORD,) | ||
|
|
||
| async def async_validate(self) -> None: | ||
| """Validate the repository. | ||
|
|
||
| Home Assistant's own loader is intentionally permissive so users can drag | ||
| files into custom_wake_words/. A published HACS repository is a curated, | ||
| single-purpose artifact, so a stricter shape is enforced here: exactly one | ||
| config (manifest) file and exactly one model file, sharing the same stem, | ||
| with the config's "model" value naming that model file (e.g. | ||
| "my_wake_word.json" + "my_wake_word.tflite" where the config contains | ||
| {"model": "my_wake_word.tflite"}). | ||
| """ | ||
| 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 | ||
| ] | ||
|
Comment on lines
+37
to
+47
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The premise here isn't accurate: So 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 |
||
|
|
||
| # Locate the config (manifest) file. hacs.json is repository metadata, not | ||
| # a wake word config, so it is ignored even when content_in_root is set. | ||
| config_files = [ | ||
| treefile | ||
| for treefile in treefiles | ||
| if treefile.filename.endswith(".json") | ||
| and treefile.filename != RepositoryFile.HACS_JSON | ||
| ] | ||
| if len(config_files) == 0: | ||
| raise ValidationException(f"No wake word config (.json) file found in {location}") | ||
| if len(config_files) > 1: | ||
| raise ValidationException( | ||
| f"Expected exactly one wake word config (.json) file in {location}, " | ||
| f"found {len(config_files)}: {', '.join(sorted(f.filename for f in config_files))}" | ||
| ) | ||
|
|
||
| config_file = config_files[0] | ||
| stem = config_file.filename.removesuffix(".json") | ||
| expected_model = f"{stem}.tflite" | ||
|
|
||
| # Locate the model file. Exactly one is required so there is no ambiguity | ||
| # about which model this repository ships. | ||
| model_files = [ | ||
| treefile.filename for treefile in treefiles if treefile.filename.endswith(".tflite") | ||
| ] | ||
| if len(model_files) == 0: | ||
| raise ValidationException(f"No wake word model (.tflite) file found in {location}") | ||
| if len(model_files) > 1: | ||
| raise ValidationException( | ||
| f"Expected exactly one wake word model (.tflite) file in {location}, " | ||
| f"found {len(model_files)}: {', '.join(sorted(model_files))}" | ||
| ) | ||
|
|
||
| # The config and model files must share the same stem. | ||
| model_filename = model_files[0] | ||
| if model_filename != expected_model: | ||
| raise ValidationException( | ||
| f"The config '{config_file.filename}' and model '{model_filename}' must share " | ||
| f"the same name; expected the model to be named '{expected_model}'" | ||
| ) | ||
|
|
||
| # Inspect the config file. | ||
| content = await self.repository.get_documentation( | ||
| filename=config_file.full_path, version=self.repository.ref | ||
| ) | ||
| if content is None: | ||
| raise ValidationException(f"Could not read '{config_file.full_path}'") | ||
| try: | ||
| config = json_loads(content) | ||
| except ValueError as exception: | ||
| raise ValidationException( | ||
| f"'{config_file.filename}' is not valid JSON: {exception}" | ||
| ) from exception | ||
| if not isinstance(config, dict): | ||
| raise ValidationException(f"'{config_file.filename}' must contain a JSON object") | ||
|
|
||
| # Required keys, mirroring Home Assistant's wake word config schema. | ||
| for key in ("type", "wake_word", "model"): | ||
| if key not in config: | ||
| raise ValidationException( | ||
| f"'{config_file.filename}' is missing the required '{key}' key" | ||
| ) | ||
|
|
||
| # The "model" value must name the model file exactly. | ||
| if config["model"] != expected_model: | ||
| raise ValidationException( | ||
| f"'{config_file.filename}' declares model '{config['model']}', " | ||
| f"but it must be '{expected_model}' to match the config file name" | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,17 @@ async def test_hacs(hacs, repository, tmpdir): | |
| await hacs.async_process_queue() | ||
|
|
||
|
|
||
| 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 | ||
|
|
||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in fd44f3c. Renamed the original test to |
||
|
|
||
| async def test_add_remove_repository(hacs, repository, tmpdir): | ||
| hacs.hass.config.config_dir = tmpdir | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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_repositorynow 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). Incontent_in_rootmode the same check runs against the repository root. Covered bytest_validate_repository_requires_manifest_and_model,test_validate_repository_missing_manifest, andtest_validate_repository_missing_model.