mirror of
https://github.com/esphome/esphome.git
synced 2026-08-17 10:52:56 +08:00
[micro_wake_word] Include the local model file in bundles (#17604)
CI / Create common environment (push) Has been cancelled
CI / Check pylint (push) Has been cancelled
CI / Run script/ci-custom (push) Has been cancelled
CI / Check import esphome.__main__ time (push) Has been cancelled
CI / Test downstream esphome/device-builder (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.12) (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.14) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.12) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.13) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.14) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.12) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.14) (push) Has been cancelled
CI / Determine which jobs to run (push) Has been cancelled
CI / Run integration tests (${{ matrix.bucket.name }}) (push) Has been cancelled
CI / Run C++ unit tests (push) Has been cancelled
CI / Run CodSpeed benchmarks (push) Has been cancelled
CI / Run script/clang-tidy for ESP8266 (push) Has been cancelled
CI / Run script/clang-tidy for RP2 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino (push) Has been cancelled
CI / Run script/clang-tidy for ZEPHYR (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 IDF (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 IDF 1/3 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 IDF 2/3 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 IDF 3/3 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 C6 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 P4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 S3 (push) Has been cancelled
CI / Test components batch (${{ matrix.batch.components }}) (push) Has been cancelled
CI / Test esp32 components with PlatformIO (push) Has been cancelled
CI / Seed pre-commit cache (push) Has been cancelled
CI / pre-commit.ci lite (push) Has been cancelled
CI / Build target branch for memory impact (push) Has been cancelled
CI / Build PR branch for memory impact (push) Has been cancelled
CI / Comment memory impact (push) Has been cancelled
CI / CI Status (push) Has been cancelled
CI / Create common environment (push) Has been cancelled
CI / Check pylint (push) Has been cancelled
CI / Run script/ci-custom (push) Has been cancelled
CI / Check import esphome.__main__ time (push) Has been cancelled
CI / Test downstream esphome/device-builder (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.12) (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.14) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.12) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.13) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.14) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.12) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.14) (push) Has been cancelled
CI / Determine which jobs to run (push) Has been cancelled
CI / Run integration tests (${{ matrix.bucket.name }}) (push) Has been cancelled
CI / Run C++ unit tests (push) Has been cancelled
CI / Run CodSpeed benchmarks (push) Has been cancelled
CI / Run script/clang-tidy for ESP8266 (push) Has been cancelled
CI / Run script/clang-tidy for RP2 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino (push) Has been cancelled
CI / Run script/clang-tidy for ZEPHYR (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 IDF (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 IDF 1/3 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 IDF 2/3 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 IDF 3/3 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 C6 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 P4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 S3 (push) Has been cancelled
CI / Test components batch (${{ matrix.batch.components }}) (push) Has been cancelled
CI / Test esp32 components with PlatformIO (push) Has been cancelled
CI / Seed pre-commit cache (push) Has been cancelled
CI / pre-commit.ci lite (push) Has been cancelled
CI / Build target branch for memory impact (push) Has been cancelled
CI / Build PR branch for memory impact (push) Has been cancelled
CI / Comment memory impact (push) Has been cancelled
CI / CI Status (push) Has been cancelled
This commit is contained in:
+35
-2
@@ -7,7 +7,7 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
import io
|
||||
import json
|
||||
@@ -32,6 +32,8 @@ from esphome.core import CORE, EsphomeError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "bundle"
|
||||
|
||||
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
|
||||
MANIFEST_FILENAME = "manifest.json"
|
||||
CURRENT_MANIFEST_VERSION = 1
|
||||
@@ -120,6 +122,32 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
|
||||
return keys
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleData:
|
||||
"""Files components asked to include, keyed under DOMAIN in CORE.data."""
|
||||
|
||||
extra_files: list[Path] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> BundleData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = BundleData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def add_bundle_file(path: Path) -> None:
|
||||
"""Register a file that a bundle must include.
|
||||
|
||||
Bundle discovery walks the validated config, so it only finds files the config
|
||||
names. Components call this during validation for files it cannot see, such as a
|
||||
file that is referenced from inside another file.
|
||||
|
||||
A relative path is taken as relative to the config directory. Files outside the
|
||||
config directory are skipped when the bundle is built.
|
||||
"""
|
||||
_get_data().extra_files.append(CORE.relative_config_path(path))
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleFile:
|
||||
"""A file to include in the bundle."""
|
||||
@@ -286,13 +314,18 @@ class ConfigBundleCreator:
|
||||
with known file extensions are also resolved and checked.
|
||||
|
||||
Core ESPHome concepts that use relative paths or directories
|
||||
are handled explicitly.
|
||||
are handled explicitly. Files the config does not name at all are
|
||||
registered by their component with add_bundle_file().
|
||||
"""
|
||||
config = self._config
|
||||
|
||||
# Generic walk: find all file paths in the validated config
|
||||
self._walk_config_for_files(config)
|
||||
|
||||
# Files registered by components during validation
|
||||
for extra_file in _get_data().extra_files:
|
||||
self._add_file(extra_file)
|
||||
|
||||
# --- Core ESPHome concepts needing explicit handling ---
|
||||
|
||||
# esphome.includes / includes_c - can be relative paths and directories
|
||||
|
||||
@@ -6,6 +6,7 @@ from urllib.parse import urljoin
|
||||
|
||||
from esphome import automation, external_files, git
|
||||
from esphome.automation import register_action, register_condition
|
||||
from esphome.bundle import add_bundle_file
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, microphone, ota, psram
|
||||
import esphome.config_validation as cv
|
||||
@@ -28,6 +29,7 @@ from esphome.const import (
|
||||
TYPE_LOCAL,
|
||||
)
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -236,10 +238,45 @@ HTTP_SCHEMA = cv.All(
|
||||
_process_http_source,
|
||||
)
|
||||
|
||||
LOCAL_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_),
|
||||
}
|
||||
|
||||
def _register_local_model_file(config: ConfigType) -> ConfigType:
|
||||
"""Register the model file that the manifest points to, so bundles include it.
|
||||
|
||||
The manifest names its model file relative to itself, so that path never appears
|
||||
in the YAML and bundle discovery cannot find it on its own.
|
||||
|
||||
Problems with the manifest are logged and ignored here rather than raised. Loading
|
||||
the manifest later reports them with better messages, and raising would be
|
||||
swallowed by the shorthand validator, which then reports a confusing error about a
|
||||
missing file in a git repository. Logging keeps the skipped registration
|
||||
diagnosable if the manifest is only briefly unreadable, since the bundle would
|
||||
then be built without the model file.
|
||||
"""
|
||||
manifest_path: Path = config[CONF_PATH]
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
model = manifest[CONF_MODEL]
|
||||
except (OSError, ValueError, KeyError, TypeError) as err:
|
||||
_LOGGER.debug("Not registering a model file from %s: %s", manifest_path, err)
|
||||
return config
|
||||
if not isinstance(model, str):
|
||||
_LOGGER.debug(
|
||||
"Not registering a model file from %s: 'model' is %s, expected a string",
|
||||
manifest_path,
|
||||
type(model).__name__,
|
||||
)
|
||||
return config
|
||||
add_bundle_file(manifest_path.parent / model)
|
||||
return config
|
||||
|
||||
|
||||
LOCAL_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_),
|
||||
}
|
||||
),
|
||||
_register_local_model_file,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for micro_wake_word local model validation."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.micro_wake_word import LOCAL_SCHEMA
|
||||
from esphome.core import CORE
|
||||
|
||||
MANIFEST: dict[str, Any] = {
|
||||
"type": "micro",
|
||||
"model": "hey_jarvis.tflite",
|
||||
"author": "someone",
|
||||
"version": 2,
|
||||
"wake_word": "hey jarvis",
|
||||
"trained_languages": ["en"],
|
||||
"micro": {
|
||||
"feature_step_size": 10,
|
||||
"tensor_arena_size": 30000,
|
||||
"probability_cutoff": 0.97,
|
||||
"sliding_window_size": 5,
|
||||
"minimum_esphome_version": "2024.7.0",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _registered_files() -> list[Path]:
|
||||
"""Files components registered for bundling this run."""
|
||||
data = CORE.data.get("bundle")
|
||||
return list(data.extra_files) if data else []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_dir(tmp_path: Path) -> Path:
|
||||
"""A config dir holding a manifest and its model file."""
|
||||
(tmp_path / "models").mkdir()
|
||||
(tmp_path / "models" / "hey_jarvis.tflite").write_bytes(b"fake model")
|
||||
(tmp_path / "models" / "hey_jarvis.json").write_text(json.dumps(MANIFEST))
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_local_schema_registers_model_file(config_dir: Path) -> None:
|
||||
"""The model file named by the manifest is registered so bundles include it."""
|
||||
LOCAL_SCHEMA({"path": "models/hey_jarvis.json"})
|
||||
|
||||
assert _registered_files() == [config_dir / "models" / "hey_jarvis.tflite"]
|
||||
|
||||
|
||||
def test_local_schema_registers_model_file_in_subdirectory(config_dir: Path) -> None:
|
||||
"""The model reference is resolved relative to the manifest, not the config dir."""
|
||||
nested = config_dir / "models" / "nested"
|
||||
nested.mkdir()
|
||||
(nested / "model.tflite").write_bytes(b"fake model")
|
||||
(config_dir / "models" / "nested.json").write_text(
|
||||
json.dumps({**MANIFEST, "model": "nested/model.tflite"})
|
||||
)
|
||||
|
||||
LOCAL_SCHEMA({"path": "models/nested.json"})
|
||||
|
||||
assert _registered_files() == [nested / "model.tflite"]
|
||||
|
||||
|
||||
def test_local_schema_leaves_config_untouched(config_dir: Path) -> None:
|
||||
"""Registration is a side effect; the model file is not a config key."""
|
||||
config = LOCAL_SCHEMA({"path": "models/hey_jarvis.json"})
|
||||
|
||||
assert config == {"path": config_dir / "models" / "hey_jarvis.json"}
|
||||
|
||||
|
||||
def test_local_schema_missing_model_file_still_validates(config_dir: Path) -> None:
|
||||
"""A model file that does not exist is registered, not rejected.
|
||||
|
||||
Raising here would be swallowed by the shorthand validator, which would then
|
||||
report a confusing error about a missing file in a git repository.
|
||||
"""
|
||||
(config_dir / "models" / "hey_jarvis.tflite").unlink()
|
||||
|
||||
LOCAL_SCHEMA({"path": "models/hey_jarvis.json"})
|
||||
|
||||
assert _registered_files() == [config_dir / "models" / "hey_jarvis.tflite"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"contents",
|
||||
[
|
||||
pytest.param("{not valid json", id="malformed"),
|
||||
pytest.param(json.dumps({"type": "micro"}), id="no_model_key"),
|
||||
pytest.param(json.dumps(["a", "list"]), id="not_an_object"),
|
||||
pytest.param(json.dumps({"model": 42}), id="model_not_a_string"),
|
||||
],
|
||||
)
|
||||
def test_local_schema_bad_manifest_does_not_raise(
|
||||
config_dir: Path, contents: str, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Manifest problems are left to later stages, which report them better.
|
||||
|
||||
The skipped registration is logged so a bundle built without the model file can
|
||||
be diagnosed.
|
||||
"""
|
||||
(config_dir / "models" / "hey_jarvis.json").write_text(contents)
|
||||
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
LOCAL_SCHEMA({"path": "models/hey_jarvis.json"})
|
||||
|
||||
assert _registered_files() == []
|
||||
assert "Not registering a model file" in caplog.text
|
||||
@@ -22,6 +22,7 @@ from esphome.bundle import (
|
||||
_add_bytes_to_tar,
|
||||
_default_target_dir,
|
||||
_find_used_secret_keys,
|
||||
add_bundle_file,
|
||||
extract_bundle,
|
||||
is_bundle_path,
|
||||
prepare_bundle_for_compile,
|
||||
@@ -611,6 +612,70 @@ def test_discover_files_includes_config(tmp_path: Path) -> None:
|
||||
assert "test.yaml" in paths
|
||||
|
||||
|
||||
def test_discover_files_includes_registered_files(tmp_path: Path) -> None:
|
||||
"""Files registered with add_bundle_file() are included.
|
||||
|
||||
The config does not name them, so discovery cannot find them on its own.
|
||||
"""
|
||||
config_dir = _setup_config_dir(
|
||||
tmp_path,
|
||||
files={"models/model.tflite": "fake model data"},
|
||||
)
|
||||
add_bundle_file(config_dir / "models" / "model.tflite")
|
||||
|
||||
creator = ConfigBundleCreator({})
|
||||
files = creator.discover_files()
|
||||
|
||||
paths = [f.path for f in files]
|
||||
assert "models/model.tflite" in paths
|
||||
|
||||
|
||||
def test_discover_files_registered_relative_file(tmp_path: Path) -> None:
|
||||
"""A relative registered path is taken as relative to the config directory.
|
||||
|
||||
Not the working directory, which is where Path.resolve() would put it.
|
||||
"""
|
||||
_setup_config_dir(
|
||||
tmp_path,
|
||||
files={"models/model.tflite": "fake model data"},
|
||||
)
|
||||
add_bundle_file(Path("models/model.tflite"))
|
||||
|
||||
creator = ConfigBundleCreator({})
|
||||
files = creator.discover_files()
|
||||
|
||||
paths = [f.path for f in files]
|
||||
assert "models/model.tflite" in paths
|
||||
|
||||
|
||||
def test_discover_files_registered_file_outside_config_dir(tmp_path: Path) -> None:
|
||||
"""A registered file outside the config directory is skipped, not bundled."""
|
||||
_setup_config_dir(tmp_path)
|
||||
outside = tmp_path / "outside.tflite"
|
||||
outside.write_text("fake model data")
|
||||
add_bundle_file(outside)
|
||||
|
||||
creator = ConfigBundleCreator({})
|
||||
files = creator.discover_files()
|
||||
|
||||
assert [f.path for f in files] == ["test.yaml"]
|
||||
|
||||
|
||||
def test_discover_files_registered_file_deduplicated(tmp_path: Path) -> None:
|
||||
"""Registering the same file twice adds it once."""
|
||||
config_dir = _setup_config_dir(
|
||||
tmp_path,
|
||||
files={"models/model.tflite": "fake model data"},
|
||||
)
|
||||
add_bundle_file(config_dir / "models" / "model.tflite")
|
||||
add_bundle_file(config_dir / "models" / "model.tflite")
|
||||
|
||||
creator = ConfigBundleCreator({})
|
||||
files = creator.discover_files()
|
||||
|
||||
assert [f.path for f in files].count("models/model.tflite") == 1
|
||||
|
||||
|
||||
def test_discover_files_finds_path_objects(tmp_path: Path) -> None:
|
||||
"""Path objects in validated config are discovered."""
|
||||
config_dir = _setup_config_dir(
|
||||
|
||||
Reference in New Issue
Block a user