From ea76073d869b83a3055ffc56f0feb9fab7f452aa Mon Sep 17 00:00:00 2001 From: n-IA-hane <49248235+n-IA-hane@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:42:24 +0200 Subject: [PATCH] [micro_wake_word] Allow selecting the inference task core (#20126) Co-authored-by: n-IA-hane Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/micro_wake_word/__init__.py | 34 ++++++++ .../micro_wake_word/micro_wake_word.cpp | 13 ++- esphome/core/defines.h | 1 + esphome/core/static_task.cpp | 6 +- esphome/core/static_task.h | 3 +- .../micro_wake_word/test.esp32-idf.yaml | 3 +- .../micro_wake_word/test.esp32-s3-idf.yaml | 1 + .../micro_wake_word/test_task_core.py | 86 +++++++++++++++++++ 8 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 tests/unit_tests/components/micro_wake_word/test_task_core.py diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 9477ebc850e..ca65fa1a5e6 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -13,6 +13,7 @@ from esphome.components.http_request import validate_url import esphome.config_validation as cv from esphome.const import ( CONF_FILE, + CONF_FRAMEWORK, CONF_ID, CONF_INTERNAL, CONF_MICROPHONE, @@ -26,10 +27,12 @@ from esphome.const import ( CONF_TYPE, CONF_URL, CONF_USERNAME, + PLATFORM_ESP32, TYPE_GIT, TYPE_LOCAL, ) from esphome.core import CORE, HexInt +import esphome.final_validate as fv from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -41,6 +44,8 @@ DEPENDENCIES = ["microphone"] DOMAIN = "micro_wake_word" +CONF_TASK_CORE = "task_core" + CONF_FEATURE_STEP_SIZE = "feature_step_size" CONF_MODELS = "models" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" @@ -407,6 +412,27 @@ def _download_http_models(config: ConfigType) -> ConfigType: return config +def _validate_task_core(config: ConfigType) -> ConfigType: + if config[CONF_TASK_CORE] != 1: + return config + esp32.only_on_variant( + supported=[ + esp32.VARIANT_ESP32, + esp32.VARIANT_ESP32S3, + esp32.VARIANT_ESP32P4, + esp32.VARIANT_ESP32H4, + esp32.VARIANT_ESP32S31, + ], + msg_prefix="micro_wake_word task_core: 1", + )(config) + sdkconfig = fv.full_config.get()[PLATFORM_ESP32][CONF_FRAMEWORK][ + esp32.CONF_SDKCONFIG_OPTIONS + ] + if sdkconfig.get("CONFIG_FREERTOS_UNICORE") == "y": + raise cv.Invalid("task_core: 1 requires FreeRTOS dual-core mode") + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -427,6 +453,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VAD): _maybe_empty_vad_schema, cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, + cv.Optional(CONF_TASK_CORE, default=-1): cv.int_range(min=-1, max=1), cv.Optional(CONF_PREFER_INTERNAL_RAM, default=False): cv.boolean, cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( @@ -509,6 +536,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( }, extra=cv.ALLOW_EXTRA, ), + _validate_task_core, _feature_step_size_validate, ) @@ -521,6 +549,12 @@ async def to_code(config): cg.add(var.set_microphone_source(mic_source)) cg.add_define("USE_MICRO_WAKE_WORD") + cg.add_define( + "MICRO_WAKE_WORD_TASK_CORE", + cg.RawExpression("tskNO_AFFINITY") + if config[CONF_TASK_CORE] == -1 + else config[CONF_TASK_CORE], + ) if config[CONF_PREFER_INTERNAL_RAM]: cg.add_define("USE_MICRO_WAKE_WORD_INTERNAL_RAM") ota.request_ota_state_listeners() diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 4ae66d69bf0..6b33a3a1b56 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -17,6 +17,10 @@ namespace esphome::micro_wake_word { +// Config validation checks the YAML, this checks the build: a unicore sdkconfig reached any other way +static_assert(MICRO_WAKE_WORD_TASK_CORE == tskNO_AFFINITY || MICRO_WAKE_WORD_TASK_CORE < portNUM_PROCESSORS, + "micro_wake_word task_core is not a core of this build"); + ESPHOME_LOG_TAG(TAG, "micro_wake_word"); static const ssize_t DETECTION_QUEUE_LENGTH = 5; @@ -84,6 +88,12 @@ static const LogString *micro_wake_word_state_to_string(State state) { void MicroWakeWord::dump_config() { ESP_LOGCONFIG(TAG, "microWakeWord:"); + // tskNO_AFFINITY is a cast expression, so this cannot be a preprocessor test; the compiler folds it + if (MICRO_WAKE_WORD_TASK_CORE == tskNO_AFFINITY) { + ESP_LOGCONFIG(TAG, " Inference task core: any"); + } else { + ESP_LOGCONFIG(TAG, " Inference task core: %d", MICRO_WAKE_WORD_TASK_CORE); + } ESP_LOGCONFIG(TAG, " models:"); for (auto &model : this->wake_word_models_) { model->log_model_config(); @@ -477,7 +487,8 @@ void MicroWakeWord::loop() { } if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, - (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) { + (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_, + MICRO_WAKE_WORD_TASK_CORE)) { FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state this->status_momentary_error(1000); } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3d14494df7a..0547ba6a82d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -562,6 +562,7 @@ #define ESPHOME_ETHERNET_PHY_REGISTER_COUNT 2 #ifdef USE_ESP32 +#define MICRO_WAKE_WORD_TASK_CORE 0 #define USE_MICRO_WAKE_WORD #define USE_MICRO_WAKE_WORD_INTERNAL_RAM #define USE_MICRO_WAKE_WORD_VAD diff --git a/esphome/core/static_task.cpp b/esphome/core/static_task.cpp index 87629c2507d..dd7309f8f09 100644 --- a/esphome/core/static_task.cpp +++ b/esphome/core/static_task.cpp @@ -7,7 +7,7 @@ namespace esphome { bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, - bool use_psram) { + bool use_psram, BaseType_t core_id) { if (this->handle_ != nullptr) { // Task is already created; must call destroy() first return false; @@ -38,8 +38,8 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size return false; } - this->handle_ = - xTaskCreateStatic(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(), this->tcb_.get()); + this->handle_ = xTaskCreateStaticPinnedToCore(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(), + this->tcb_.get(), core_id); if (this->handle_ == nullptr) { this->deallocate(); return false; diff --git a/esphome/core/static_task.h b/esphome/core/static_task.h index 10c55f70c62..999e92a4b41 100644 --- a/esphome/core/static_task.h +++ b/esphome/core/static_task.h @@ -37,9 +37,10 @@ class StaticTask { /// @param param Parameter passed to task function /// @param priority FreeRTOS task priority /// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM + /// @param core_id CPU core, or tskNO_AFFINITY to leave scheduling unrestricted /// @return true on success; false if either allocation fails or the task cannot be created bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, - bool use_psram); + bool use_psram, BaseType_t core_id = tskNO_AFFINITY); /// @brief Delete the task, keeping the TCB and stack buffer allocated for reuse by a subsequent create() call. /// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is diff --git a/tests/components/micro_wake_word/test.esp32-idf.yaml b/tests/components/micro_wake_word/test.esp32-idf.yaml index fa3984d57e0..726013a0602 100644 --- a/tests/components/micro_wake_word/test.esp32-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-idf.yaml @@ -3,5 +3,4 @@ substitutions: packages: i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml - -<<: !include common.yaml + micro_wake_word: !include common.yaml diff --git a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml index 8806d8046d9..3ed671a764d 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -6,4 +6,5 @@ packages: micro_wake_word: !include common.yaml micro_wake_word: + task_core: 1 prefer_internal_ram: true diff --git a/tests/unit_tests/components/micro_wake_word/test_task_core.py b/tests/unit_tests/components/micro_wake_word/test_task_core.py new file mode 100644 index 00000000000..563022a2400 --- /dev/null +++ b/tests/unit_tests/components/micro_wake_word/test_task_core.py @@ -0,0 +1,86 @@ +"""Validate inference task affinity before generating firmware.""" + +import pytest + +from esphome.components import audio, micro_wake_word as mww +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_ESP32, KEY_TARGET_PLATFORM, KEY_VARIANT +from esphome.core import CORE +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + "variant", + ["ESP32", "ESP32S3", "ESP32P4", "ESP32H4", "ESP32S31", "ESP32S2", "ESP32C3"], +) +@pytest.mark.parametrize("core", [-1, 0, 1]) +def test_task_core_matches_target(variant: str, core: int) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + token = fv.full_config.set({"esp32": {"framework": {"sdkconfig_options": {}}}}) + try: + config = mww.CONFIG_SCHEMA({"task_core": core}) + if core == 1 and variant in ("ESP32S2", "ESP32C3"): + with pytest.raises(cv.Invalid, match="only available"): + mww._validate_task_core(config) + else: + assert mww._validate_task_core(config)["task_core"] == core + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("core", [-2, 2]) +def test_invalid_task_core(core: int) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + with pytest.raises(cv.Invalid): + mww.CONFIG_SCHEMA({"task_core": core}) + + +def test_default_task_core_is_unpinned() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + assert mww.CONFIG_SCHEMA({})["task_core"] == -1 + + +@pytest.mark.parametrize("unicore", ["y", "n", "true", "1"]) +def test_core_one_matches_freertos_unicore_setting(unicore: str) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"} + token = fv.full_config.set( + { + "esp32": { + "framework": {"sdkconfig_options": {"CONFIG_FREERTOS_UNICORE": unicore}} + } + } + ) + try: + if unicore == "y": + with pytest.raises(cv.Invalid, match="dual-core"): + mww._validate_task_core({"task_core": 1}) + else: + assert mww._validate_task_core({"task_core": 1})["task_core"] == 1 + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("variant", ["ESP32S3", "ESP32C3"]) +def test_task_core_final_validation( + variant: str, monkeypatch: pytest.MonkeyPatch +) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + # Isolate microphone capabilities; exercise the real final-validation chain. + monkeypatch.setattr( + audio, + "final_validate_audio_schema", + lambda *args, **kwargs: lambda config: config, + ) + token = fv.full_config.set({"esp32": {"framework": {"sdkconfig_options": {}}}}) + try: + config = mww.CONFIG_SCHEMA({"task_core": 1}) + if variant == "ESP32C3": + with pytest.raises(cv.Invalid, match="only available"): + mww.FINAL_VALIDATE_SCHEMA(config) + else: + mww.FINAL_VALIDATE_SCHEMA(config) + finally: + fv.full_config.reset(token)