Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions custom_components/hacs/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions custom_components/hacs/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions custom_components/hacs/repositories/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,4 +19,5 @@
HacsCategory.APPDAEMON: HacsAppdaemonRepository,
HacsCategory.PLUGIN: HacsPluginRepository,
HacsCategory.TEMPLATE: HacsTemplateRepository,
HacsCategory.WAKE_WORD: HacsWakeWordRepository,
}
84 changes: 84 additions & 0 deletions custom_components/hacs/repositories/wake_word.py
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:

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.

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,
},
)
1 change: 1 addition & 0 deletions custom_components/hacs/utils/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions custom_components/hacs/validate/wake_word_model.py
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

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.


# 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"
)
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
HacsPythonScriptRepository,
HacsTemplateRepository,
HacsThemeRepository,
HacsWakeWordRepository,
)
from custom_components.hacs.utils.store import async_load_from_store

Expand Down Expand Up @@ -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"""
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/proxy/data-v2.hacs.xyz/wake_word/data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
11 changes: 11 additions & 0 deletions tests/hacsbase/test_hacs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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).


async def test_add_remove_repository(hacs, repository, tmpdir):
hacs.hass.config.config_dir = tmpdir

Expand Down
Loading