diff --git a/custom_components/hacs/base.py b/custom_components/hacs/base.py index 874fe5b0024..f554e5562ed 100644 --- a/custom_components/hacs/base.py +++ b/custom_components/hacs/base.py @@ -792,6 +792,11 @@ def set_active_categories(self) -> None: if self.configuration.appdaemon: self.enable_hacs_category(HacsCategory.APPDAEMON) + if "esphome" in self.hass.config.components or self.repositories.category_downloaded( + HacsCategory.WAKE_WORD + ): + self.enable_hacs_category(HacsCategory.WAKE_WORD) + async def async_load_hacs_from_github(self, _=None) -> None: """Load HACS from GitHub.""" if self.status.inital_fetch_done: diff --git a/custom_components/hacs/enums.py b/custom_components/hacs/enums.py index ad0e34842fd..15d1c749354 100644 --- a/custom_components/hacs/enums.py +++ b/custom_components/hacs/enums.py @@ -18,6 +18,7 @@ class HacsCategory(StrEnum): PYTHON_SCRIPT = "python_script" TEMPLATE = "template" THEME = "theme" + WAKE_WORD = "wake_word" def __str__(self): return str(self.value) diff --git a/custom_components/hacs/repositories/__init__.py b/custom_components/hacs/repositories/__init__.py index e19ca33ec5d..0890ae07023 100644 --- a/custom_components/hacs/repositories/__init__.py +++ b/custom_components/hacs/repositories/__init__.py @@ -10,6 +10,7 @@ from .python_script import HacsPythonScriptRepository from .template import HacsTemplateRepository from .theme import HacsThemeRepository +from .wake_word import HacsWakeWordRepository REPOSITORY_CLASSES: dict[HacsCategory, HacsRepository] = { HacsCategory.THEME: HacsThemeRepository, @@ -18,4 +19,5 @@ HacsCategory.APPDAEMON: HacsAppdaemonRepository, HacsCategory.PLUGIN: HacsPluginRepository, HacsCategory.TEMPLATE: HacsTemplateRepository, + HacsCategory.WAKE_WORD: HacsWakeWordRepository, } diff --git a/custom_components/hacs/repositories/wake_word.py b/custom_components/hacs/repositories/wake_word.py new file mode 100644 index 00000000000..215aa2f5516 --- /dev/null +++ b/custom_components/hacs/repositories/wake_word.py @@ -0,0 +1,124 @@ +"""Class for wake word models in HACS.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.exceptions import HomeAssistantError + +from ..enums import HacsCategory, HacsDispatchEvent, RepositoryFile +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. + + The full name (owner/repo) is used, rather than just the repo name, so + that two repositories with the same repo name but 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. + """ + return f"{self.hacs.core.config_path}/custom_wake_words/{self.data.full_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 = "" + + # Home Assistant discovers wake words by scanning for config (.json) + # manifests and then loading the model each one names, so a compliant + # repository must provide both a manifest and a model. hacs.json is + # repository metadata, not a wake word config, so it is ignored. + has_manifest = False + has_model = False + for treefile in self.treefiles: + if not treefile.startswith(self.content.path.remote): + continue + if treefile.endswith(".tflite"): + has_model = True + elif treefile.endswith(".json") and not treefile.endswith(RepositoryFile.HACS_JSON): + has_manifest = True + if not (has_manifest and has_model): + 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 + + async def async_post_installation(self): + """Run post installation steps.""" + await self._reload_custom_wake_words() + + async def async_post_uninstall(self): + """Run post uninstall steps.""" + await self._reload_custom_wake_words() + + async def _reload_custom_wake_words(self) -> None: + """Ask esphome to rescan the custom wake word directory. + + The wake word inventory is cached for the lifetime of the Home Assistant + process, so installing, updating or removing a model has no effect until + the cache is invalidated. The esphome integration exposes a service that + does this and refreshes the satellites. + """ + if not self.hacs.hass.services.has_service("esphome", "reload_custom_wake_words"): + return + self.logger.debug("%s Reloading custom wake words", self.string) + try: + await self.hacs.hass.services.async_call("esphome", "reload_custom_wake_words", {}) + except HomeAssistantError as exception: + self.logger.exception("%s %s", self.string, exception) + + @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, + }, + ) diff --git a/custom_components/hacs/utils/validate.py b/custom_components/hacs/utils/validate.py index fa25be9af8a..e41a1f16a2f 100644 --- a/custom_components/hacs/utils/validate.py +++ b/custom_components/hacs/utils/validate.py @@ -141,6 +141,7 @@ def validate_version(data: Any) -> Any: "python_script": V2_COMMON_DATA_JSON_SCHEMA, "template": V2_COMMON_DATA_JSON_SCHEMA, "theme": V2_COMMON_DATA_JSON_SCHEMA, + "wake_word": V2_COMMON_DATA_JSON_SCHEMA, } # Used when validating repos in the hacs integration, discards extra keys diff --git a/custom_components/hacs/validate/wake_word_model.py b/custom_components/hacs/validate/wake_word_model.py new file mode 100644 index 00000000000..cbe983f668c --- /dev/null +++ b/custom_components/hacs/validate/wake_word_model.py @@ -0,0 +1,138 @@ +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 + ] + + # A repository must ship a single flat manifest+model pair. Reject wake + # word files nested in a subdirectory so a second model cannot slip in + # unnoticed (and so the manifest and model never end up in different + # directories). Skipped for content_in_root, where there is no subtree. + if content_path: + nested = sorted( + treefile.full_path + for treefile in self.repository.tree + if not treefile.is_directory + and treefile.path.startswith(f"{content_path}/") + and ( + treefile.filename.endswith(".tflite") + or treefile.filename.endswith(".json") + ) + ) + if nested: + raise ValidationException( + f"Wake word files must be directly in '{content_path}/', " + f"not in a subdirectory: {', '.join(nested)}" + ) + + # 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" + ) diff --git a/tests/conftest.py b/tests/conftest.py index acc012c856d..78eaaba93df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,6 +41,7 @@ HacsPythonScriptRepository, HacsTemplateRepository, HacsThemeRepository, + HacsWakeWordRepository, ) from custom_components.hacs.utils.store import async_load_from_store @@ -237,6 +238,13 @@ def repository_template(hacs): return dummy_repository_base(hacs, repository_obj) +@pytest.fixture +def repository_wake_word(hacs): + """Fixtrue for HACS wake word repository object""" + repository_obj = HacsWakeWordRepository(hacs, "test/test") + return dummy_repository_base(hacs, repository_obj) + + @pytest.fixture def repository_appdaemon(hacs): """Fixtrue for HACS appdaemon repository object""" diff --git a/tests/fixtures/proxy/data-v2.hacs.xyz/wake_word/data.json b/tests/fixtures/proxy/data-v2.hacs.xyz/wake_word/data.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/tests/fixtures/proxy/data-v2.hacs.xyz/wake_word/data.json @@ -0,0 +1 @@ +{} diff --git a/tests/hacsbase/test_hacs.py b/tests/hacsbase/test_hacs.py index aae7d18be1b..84e6b55d9a9 100644 --- a/tests/hacsbase/test_hacs.py +++ b/tests/hacsbase/test_hacs.py @@ -33,6 +33,34 @@ async def test_hacs(hacs, repository, tmpdir): await hacs.async_process_queue() +async def test_wake_word_category_enabled_by_esphome(hacs): + """The wake_word category is 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 + + +async def test_wake_word_category_enabled_when_downloaded(hacs, repository_wake_word): + """The wake_word category is active when a model is already downloaded.""" + assert "esphome" not in hacs.hass.config.components + + hacs.set_active_categories() + assert HacsCategory.WAKE_WORD not in hacs.common.categories + + # A downloaded wake_word repository keeps the category active even without + # esphome loaded (e.g. after a restart, before esphome has set up). + repository_wake_word.data.installed = True + hacs.repositories.register(repository_wake_word) + assert hacs.repositories.category_downloaded(HacsCategory.WAKE_WORD) + + hacs.set_active_categories() + assert HacsCategory.WAKE_WORD in hacs.common.categories + + async def test_add_remove_repository(hacs, repository, tmpdir): hacs.hass.config.config_dir = tmpdir diff --git a/tests/repositories/test_wake_word_repository.py b/tests/repositories/test_wake_word_repository.py new file mode 100644 index 00000000000..85e8ec15678 --- /dev/null +++ b/tests/repositories/test_wake_word_repository.py @@ -0,0 +1,93 @@ +"""Tests for specific wake word repository implementations.""" + +import pytest + +from homeassistant.core import ServiceCall + +from custom_components.hacs.exceptions import HacsException + + +async def _stub_common_validate(repository): + """Replace common_validate so validate_repository can run in isolation.""" + + async def _noop(*_, **__): + return None + + repository.common_validate = _noop + + +def test_localpath_uses_full_name(repository_wake_word): + """The install path is namespaced by the full name (owner/repo).""" + config_path = repository_wake_word.hacs.core.config_path + repository_wake_word.data.full_name = "octocat/okay_nabu" + + assert ( + repository_wake_word.localpath == f"{config_path}/custom_wake_words/octocat/okay_nabu" + ) + + +def test_localpath_deconflicts_same_repo_name_different_owner(repository_wake_word): + """Two repos sharing a repo name but different owners must not collide.""" + repository_wake_word.data.full_name = "alice/okay_nabu" + alice_path = repository_wake_word.localpath + + repository_wake_word.data.full_name = "bob/okay_nabu" + bob_path = repository_wake_word.localpath + + assert alice_path != bob_path + + +async def test_validate_repository_requires_manifest_and_model(repository_wake_word): + """A compliant repository provides both a manifest and a model.""" + await _stub_common_validate(repository_wake_word) + repository_wake_word.treefiles = [ + "custom_wake_words/my_wake_word.json", + "custom_wake_words/my_wake_word.tflite", + ] + + assert await repository_wake_word.validate_repository() + + +async def test_validate_repository_missing_manifest(repository_wake_word): + """A model with no config manifest is not compliant (HA loads manifests).""" + await _stub_common_validate(repository_wake_word) + repository_wake_word.treefiles = ["custom_wake_words/my_wake_word.tflite"] + + with pytest.raises(HacsException): + await repository_wake_word.validate_repository() + + +async def test_validate_repository_missing_model(repository_wake_word): + """A manifest with no model is not compliant.""" + await _stub_common_validate(repository_wake_word) + repository_wake_word.treefiles = ["custom_wake_words/my_wake_word.json"] + + with pytest.raises(HacsException): + await repository_wake_word.validate_repository() + + +@pytest.mark.parametrize("hook", ["async_post_installation", "async_post_uninstall"]) +async def test_reloads_custom_wake_words(repository_wake_word, hook): + """Install/uninstall ask esphome to rescan the wake word directory.""" + hass = repository_wake_word.hacs.hass + calls: list[ServiceCall] = [] + + async def _handler(call: ServiceCall) -> None: + calls.append(call) + + hass.services.async_register("esphome", "reload_custom_wake_words", _handler) + + await getattr(repository_wake_word, hook)() + await hass.async_block_till_done() + + assert len(calls) == 1 + + +@pytest.mark.parametrize("hook", ["async_post_installation", "async_post_uninstall"]) +async def test_reload_noop_without_esphome(repository_wake_word, hook): + """When esphome is not loaded the hooks are a no-op and do not raise.""" + hass = repository_wake_word.hacs.hass + assert not hass.services.has_service("esphome", "reload_custom_wake_words") + + # Should not raise. + await getattr(repository_wake_word, hook)() diff --git a/tests/snapshots/api-usage/tests/hacsbase/test_hacstest-wake-word-category-enabled-by-esphome.json b/tests/snapshots/api-usage/tests/hacsbase/test_hacstest-wake-word-category-enabled-by-esphome.json new file mode 100644 index 00000000000..c534d781659 --- /dev/null +++ b/tests/snapshots/api-usage/tests/hacsbase/test_hacstest-wake-word-category-enabled-by-esphome.json @@ -0,0 +1,9 @@ +{ + "tests/hacsbase/test_hacs.py::test_wake_word_category_enabled_by_esphome": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/hacsbase/test_hacstest-wake-word-category-enabled-when-downloaded.json b/tests/snapshots/api-usage/tests/hacsbase/test_hacstest-wake-word-category-enabled-when-downloaded.json new file mode 100644 index 00000000000..e806bed373d --- /dev/null +++ b/tests/snapshots/api-usage/tests/hacsbase/test_hacstest-wake-word-category-enabled-when-downloaded.json @@ -0,0 +1,9 @@ +{ + "tests/hacsbase/test_hacs.py::test_wake_word_category_enabled_when_downloaded": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-localpath-deconflicts-same-repo-name-different-owner.json b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-localpath-deconflicts-same-repo-name-different-owner.json new file mode 100644 index 00000000000..31c6bc3f213 --- /dev/null +++ b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-localpath-deconflicts-same-repo-name-different-owner.json @@ -0,0 +1,9 @@ +{ + "tests/repositories/test_wake_word_repository.py::test_localpath_deconflicts_same_repo_name_different_owner": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-localpath-uses-full-name.json b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-localpath-uses-full-name.json new file mode 100644 index 00000000000..136c5078c98 --- /dev/null +++ b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-localpath-uses-full-name.json @@ -0,0 +1,9 @@ +{ + "tests/repositories/test_wake_word_repository.py::test_localpath_uses_full_name": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-reload-noop-without-esphome-async-post-installation.json b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-reload-noop-without-esphome-async-post-installation.json new file mode 100644 index 00000000000..e65333f5725 --- /dev/null +++ b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-reload-noop-without-esphome-async-post-installation.json @@ -0,0 +1,9 @@ +{ + "tests/repositories/test_wake_word_repository.py::test_reload_noop_without_esphome[async_post_installation]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-reload-noop-without-esphome-async-post-uninstall.json b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-reload-noop-without-esphome-async-post-uninstall.json new file mode 100644 index 00000000000..9e6d4a6bdd6 --- /dev/null +++ b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-reload-noop-without-esphome-async-post-uninstall.json @@ -0,0 +1,9 @@ +{ + "tests/repositories/test_wake_word_repository.py::test_reload_noop_without_esphome[async_post_uninstall]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-reloads-custom-wake-words-async-post-installation.json b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-reloads-custom-wake-words-async-post-installation.json new file mode 100644 index 00000000000..9abd178b6f9 --- /dev/null +++ b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-reloads-custom-wake-words-async-post-installation.json @@ -0,0 +1,9 @@ +{ + "tests/repositories/test_wake_word_repository.py::test_reloads_custom_wake_words[async_post_installation]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-reloads-custom-wake-words-async-post-uninstall.json b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-reloads-custom-wake-words-async-post-uninstall.json new file mode 100644 index 00000000000..240fc9bea56 --- /dev/null +++ b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-reloads-custom-wake-words-async-post-uninstall.json @@ -0,0 +1,9 @@ +{ + "tests/repositories/test_wake_word_repository.py::test_reloads_custom_wake_words[async_post_uninstall]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-validate-repository-missing-manifest.json b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-validate-repository-missing-manifest.json new file mode 100644 index 00000000000..47c6bb8eae5 --- /dev/null +++ b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-validate-repository-missing-manifest.json @@ -0,0 +1,9 @@ +{ + "tests/repositories/test_wake_word_repository.py::test_validate_repository_missing_manifest": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-validate-repository-missing-model.json b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-validate-repository-missing-model.json new file mode 100644 index 00000000000..bbf2abba571 --- /dev/null +++ b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-validate-repository-missing-model.json @@ -0,0 +1,9 @@ +{ + "tests/repositories/test_wake_word_repository.py::test_validate_repository_missing_model": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-validate-repository-requires-manifest-and-model.json b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-validate-repository-requires-manifest-and-model.json new file mode 100644 index 00000000000..6a3ade61d87 --- /dev/null +++ b/tests/snapshots/api-usage/tests/repositories/test_wake_word_repositorytest-validate-repository-requires-manifest-and-model.json @@ -0,0 +1,9 @@ +{ + "tests/repositories/test_wake_word_repository.py::test_validate_repository_requires_manifest_and_model": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-config-and-model-stem-mismatch.json b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-config-and-model-stem-mismatch.json new file mode 100644 index 00000000000..fa2b9cb2a8e --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-config-and-model-stem-mismatch.json @@ -0,0 +1,9 @@ +{ + "tests/validate/test_wake_word_model_check.py::test_config_and_model_stem_mismatch": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-invalid-json.json b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-invalid-json.json new file mode 100644 index 00000000000..0f6cea11052 --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-invalid-json.json @@ -0,0 +1,9 @@ +{ + "tests/validate/test_wake_word_model_check.py::test_invalid_json": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-missing-model-file.json b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-missing-model-file.json new file mode 100644 index 00000000000..8134b90e19c --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-missing-model-file.json @@ -0,0 +1,9 @@ +{ + "tests/validate/test_wake_word_model_check.py::test_missing_model_file": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-missing-required-key.json b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-missing-required-key.json new file mode 100644 index 00000000000..bef6d4b0f3c --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-missing-required-key.json @@ -0,0 +1,9 @@ +{ + "tests/validate/test_wake_word_model_check.py::test_missing_required_key": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-model-name-mismatch.json b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-model-name-mismatch.json new file mode 100644 index 00000000000..62f5b9405d2 --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-model-name-mismatch.json @@ -0,0 +1,9 @@ +{ + "tests/validate/test_wake_word_model_check.py::test_model_name_mismatch": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-multiple-config-files.json b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-multiple-config-files.json new file mode 100644 index 00000000000..fbae5dcb298 --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-multiple-config-files.json @@ -0,0 +1,9 @@ +{ + "tests/validate/test_wake_word_model_check.py::test_multiple_config_files": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-multiple-model-files.json b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-multiple-model-files.json new file mode 100644 index 00000000000..54e5faa2acd --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-multiple-model-files.json @@ -0,0 +1,9 @@ +{ + "tests/validate/test_wake_word_model_check.py::test_multiple_model_files": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-nested-wake-word-files-rejected.json b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-nested-wake-word-files-rejected.json new file mode 100644 index 00000000000..c39b7b2b6ff --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-nested-wake-word-files-rejected.json @@ -0,0 +1,9 @@ +{ + "tests/validate/test_wake_word_model_check.py::test_nested_wake_word_files_rejected": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-no-config-file.json b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-no-config-file.json new file mode 100644 index 00000000000..d329d3be01c --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-no-config-file.json @@ -0,0 +1,9 @@ +{ + "tests/validate/test_wake_word_model_check.py::test_no_config_file": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-valid-wake-word-repository-content-in-root.json b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-valid-wake-word-repository-content-in-root.json new file mode 100644 index 00000000000..5fbf88de304 --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-valid-wake-word-repository-content-in-root.json @@ -0,0 +1,9 @@ +{ + "tests/validate/test_wake_word_model_check.py::test_valid_wake_word_repository_content_in_root": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-valid-wake-word-repository.json b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-valid-wake-word-repository.json new file mode 100644 index 00000000000..41c5056b62e --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_wake_word_model_checktest-valid-wake-word-repository.json @@ -0,0 +1,9 @@ +{ + "tests/validate/test_wake_word_model_check.py::test_valid_wake_word_repository": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/validate/test_wake_word_model_check.py b/tests/validate/test_wake_word_model_check.py new file mode 100644 index 00000000000..8902a6aa6e2 --- /dev/null +++ b/tests/validate/test_wake_word_model_check.py @@ -0,0 +1,170 @@ +import json + +from aiogithubapi.objects.repository.content import AIOGitHubAPIRepositoryTreeContent + +from custom_components.hacs.validate.wake_word_model import Validator + + +def _tree(*paths): + """Build a repository tree from a list of file paths.""" + return [ + AIOGitHubAPIRepositoryTreeContent({"path": path, "type": "blob"}, "test/test", "main") + for path in paths + ] + + +def _valid_config(model="my_wake_word.tflite"): + return { + "type": "micro", + "wake_word": "My Wake Word", + "model": model, + } + + +def _set_documentation(repository, content): + """Make get_documentation return the given content.""" + + async def _get_documentation(**__): + return content + + repository.get_documentation = _get_documentation + + +async def test_valid_wake_word_repository(repository_wake_word): + repository_wake_word.tree = _tree( + "custom_wake_words/my_wake_word.json", + "custom_wake_words/my_wake_word.tflite", + ) + _set_documentation(repository_wake_word, json.dumps(_valid_config())) + + check = Validator(repository_wake_word) + await check.execute_validation() + assert not check.failed + + +async def test_nested_wake_word_files_rejected(repository_wake_word): + """Wake word files in a subdirectory of the content dir are rejected.""" + repository_wake_word.tree = _tree( + "custom_wake_words/my_wake_word.json", + "custom_wake_words/my_wake_word.tflite", + "custom_wake_words/extra/second.json", + "custom_wake_words/extra/second.tflite", + ) + _set_documentation(repository_wake_word, json.dumps(_valid_config())) + + check = Validator(repository_wake_word) + await check.execute_validation() + assert check.failed + + +async def test_valid_wake_word_repository_content_in_root(repository_wake_word): + """hacs.json in the root must not be mistaken for the wake word config.""" + repository_wake_word.repository_manifest.content_in_root = True + repository_wake_word.tree = _tree( + "hacs.json", + "my_wake_word.json", + "my_wake_word.tflite", + ) + _set_documentation(repository_wake_word, json.dumps(_valid_config())) + + check = Validator(repository_wake_word) + await check.execute_validation() + assert not check.failed + + +async def test_no_config_file(repository_wake_word): + repository_wake_word.tree = _tree("custom_wake_words/my_wake_word.tflite") + + check = Validator(repository_wake_word) + await check.execute_validation() + assert check.failed + + +async def test_multiple_config_files(repository_wake_word): + repository_wake_word.tree = _tree( + "custom_wake_words/one.json", + "custom_wake_words/one.tflite", + "custom_wake_words/two.json", + "custom_wake_words/two.tflite", + ) + _set_documentation(repository_wake_word, json.dumps(_valid_config())) + + check = Validator(repository_wake_word) + await check.execute_validation() + assert check.failed + + +async def test_invalid_json(repository_wake_word): + repository_wake_word.tree = _tree( + "custom_wake_words/my_wake_word.json", + "custom_wake_words/my_wake_word.tflite", + ) + _set_documentation(repository_wake_word, "{not valid json") + + check = Validator(repository_wake_word) + await check.execute_validation() + assert check.failed + + +async def test_missing_required_key(repository_wake_word): + repository_wake_word.tree = _tree( + "custom_wake_words/my_wake_word.json", + "custom_wake_words/my_wake_word.tflite", + ) + config = _valid_config() + del config["wake_word"] + _set_documentation(repository_wake_word, json.dumps(config)) + + check = Validator(repository_wake_word) + await check.execute_validation() + assert check.failed + + +async def test_model_name_mismatch(repository_wake_word): + """config["model"] must match the config file stem.""" + repository_wake_word.tree = _tree( + "custom_wake_words/my_wake_word.json", + "custom_wake_words/my_wake_word.tflite", + ) + _set_documentation(repository_wake_word, json.dumps(_valid_config(model="other.tflite"))) + + check = Validator(repository_wake_word) + await check.execute_validation() + assert check.failed + + +async def test_missing_model_file(repository_wake_word): + """The model referenced by the config must exist in the directory.""" + repository_wake_word.tree = _tree("custom_wake_words/my_wake_word.json") + _set_documentation(repository_wake_word, json.dumps(_valid_config())) + + check = Validator(repository_wake_word) + await check.execute_validation() + assert check.failed + + +async def test_multiple_model_files(repository_wake_word): + """A repository must ship exactly one model file.""" + repository_wake_word.tree = _tree( + "custom_wake_words/my_wake_word.json", + "custom_wake_words/my_wake_word.tflite", + "custom_wake_words/extra.tflite", + ) + _set_documentation(repository_wake_word, json.dumps(_valid_config())) + + check = Validator(repository_wake_word) + await check.execute_validation() + assert check.failed + + +async def test_config_and_model_stem_mismatch(repository_wake_word): + """The config file and model file must share the same stem.""" + repository_wake_word.tree = _tree( + "custom_wake_words/my_wake_word.json", + "custom_wake_words/other.tflite", + ) + _set_documentation(repository_wake_word, json.dumps(_valid_config(model="other.tflite"))) + + check = Validator(repository_wake_word) + await check.execute_validation() + assert check.failed