Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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,
}
124 changes: 124 additions & 0 deletions custom_components/hacs/repositories/wake_word.py
Original file line number Diff line number Diff line change
@@ -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,
},
)
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
138 changes: 138 additions & 0 deletions custom_components/hacs/validate/wake_word_model.py
Original file line number Diff line number Diff line change
@@ -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"
)
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 @@
{}
28 changes: 28 additions & 0 deletions tests/hacsbase/test_hacs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading