[micro_wake_word] Allow selecting the inference task core (#20126)

Co-authored-by: n-IA-hane <n-IA-hane@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
n-IA-hane
2026-10-14 12:42:24 -10:00
committed by GitHub
co-authored by n-IA-hane J. Nick Koston J. Nick Koston
parent dcb943ded3
commit ea76073d86
8 changed files with 140 additions and 7 deletions
@@ -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()
@@ -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);
}
+1
View File
@@ -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
+3 -3
View File
@@ -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;
+2 -1
View File
@@ -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
@@ -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
@@ -6,4 +6,5 @@ packages:
micro_wake_word: !include common.yaml
micro_wake_word:
task_core: 1
prefer_internal_ram: true
@@ -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)