From 320ba30d509e134638f9760e61f6e307bd03f61a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:49:57 +1100 Subject: [PATCH 1/8] [esp32] Add build flag to suppress noexecstack message (#12272) --- esphome/components/esp32/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d372af3e6..d5d5195e9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -860,6 +860,7 @@ async def to_code(config): ) cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") + cg.add_build_flag("-Wl,-z,noexecstack") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") From f0673f63045c0875610c53dd40403dc45bffc199 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Dec 2025 02:25:13 -0500 Subject: [PATCH 2/8] [ld2420] Add missing USE_SELECT ifdefs (#12275) Co-authored-by: Claude --- esphome/components/ld2420/ld2420.cpp | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index f544acc11..f6182e497 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -205,8 +205,10 @@ void LD2420Component::dump_config() { LOG_BUTTON(" ", "Factory Reset:", this->factory_reset_button_); LOG_BUTTON(" ", "Restart Module:", this->restart_module_button_); #endif +#ifdef USE_SELECT ESP_LOGCONFIG(TAG, "Select:"); LOG_SELECT(" ", "Operating Mode", this->operating_selector_); +#endif if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) { ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_); } @@ -238,12 +240,20 @@ void LD2420Component::setup() { memcpy(&this->new_config, &this->current_config, sizeof(this->current_config)); if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) { this->set_operating_mode(OP_SIMPLE_MODE_STRING); - this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING); +#ifdef USE_SELECT + if (this->operating_selector_ != nullptr) { + this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING); + } +#endif this->set_mode_(CMD_SYSTEM_MODE_SIMPLE); ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_); } else { this->set_mode_(CMD_SYSTEM_MODE_ENERGY); - this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING); +#ifdef USE_SELECT + if (this->operating_selector_ != nullptr) { + this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING); + } +#endif } #ifdef USE_NUMBER this->init_gate_config_numbers(); @@ -383,8 +393,12 @@ void LD2420Component::set_operating_mode(const char *state) { // If unsupported firmware ignore mode select if (ld2420::get_firmware_int(firmware_ver_) >= CALIBRATE_VERSION_MIN) { this->current_operating_mode = find_uint8(OP_MODE_BY_STR, state); - // Entering Auto Calibrate we need to clear the privoiuos data collection - this->operating_selector_->publish_state(state); + // Entering Auto Calibrate we need to clear the previous data collection +#ifdef USE_SELECT + if (this->operating_selector_ != nullptr) { + this->operating_selector_->publish_state(state); + } +#endif if (current_operating_mode == OP_CALIBRATE_MODE) { this->set_calibration_(true); for (uint8_t gate = 0; gate < TOTAL_GATES; gate++) { @@ -404,7 +418,11 @@ void LD2420Component::set_operating_mode(const char *state) { } } else { this->current_operating_mode = OP_SIMPLE_MODE; - this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING); +#ifdef USE_SELECT + if (this->operating_selector_ != nullptr) { + this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING); + } +#endif } } From b18e3d943ab2b23e47a597c6d14e8f34d444fde1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 4 Dec 2025 19:20:55 +1100 Subject: [PATCH 3/8] [config] Provide path for `has_at_most_one_of` messages (#12277) --- esphome/config_validation.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index a3fd271a8..ee926b1b6 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -740,9 +740,10 @@ def has_at_most_one_key(*keys): if not isinstance(obj, dict): raise Invalid("expected dictionary") - number = sum(k in keys for k in obj) - if number > 1: - raise Invalid(f"Cannot specify more than one of {', '.join(keys)}.") + used = set(obj) & set(keys) + if len(used) > 1: + msg = "Cannot specify more than one of '" + "', '".join(used) + "'." + raise MultipleInvalid([Invalid(msg, path=[k]) for k in used]) return obj return validate From 1b53fcf634255705ffefa9d79336740a0f8c45eb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:00:45 -0500 Subject: [PATCH 4/8] [es8311] Remove MIN and MAX from mic_gain enum options (#12281) Co-authored-by: Claude --- esphome/components/es8311/audio_dac.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/es8311/audio_dac.py b/esphome/components/es8311/audio_dac.py index 7d80cfd5f..5941a8193 100644 --- a/esphome/components/es8311/audio_dac.py +++ b/esphome/components/es8311/audio_dac.py @@ -22,7 +22,6 @@ ES8311_BITS_PER_SAMPLE_ENUM = { es8311_mic_gain = es8311_ns.enum("ES8311MicGain") ES8311_MIC_GAIN_ENUM = { - "MIN": es8311_mic_gain.ES8311_MIC_GAIN_MIN, "0DB": es8311_mic_gain.ES8311_MIC_GAIN_0DB, "6DB": es8311_mic_gain.ES8311_MIC_GAIN_6DB, "12DB": es8311_mic_gain.ES8311_MIC_GAIN_12DB, @@ -31,7 +30,6 @@ ES8311_MIC_GAIN_ENUM = { "30DB": es8311_mic_gain.ES8311_MIC_GAIN_30DB, "36DB": es8311_mic_gain.ES8311_MIC_GAIN_36DB, "42DB": es8311_mic_gain.ES8311_MIC_GAIN_42DB, - "MAX": es8311_mic_gain.ES8311_MIC_GAIN_MAX, } From 44148c0c6b93534ecd66342215f19e71533c77f7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:47:21 -0500 Subject: [PATCH 5/8] [esp32_hosted] Fix build and bump IDF component version to 2.7.0 (#12282) Co-authored-by: Claude --- esphome/components/esp32_hosted/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index fde75517e..9c9d1d4bb 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -93,9 +93,9 @@ async def to_code(config): framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] os.environ["ESP_IDF_VERSION"] = f"{framework_ver.major}.{framework_ver.minor}" if framework_ver >= cv.Version(5, 5, 0): - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.1.5") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.2.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.3") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.6.1") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.7.0") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") From ef342390644d7dfdfb8928bb951635719172a13b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:00:08 +1300 Subject: [PATCH 6/8] [CI] Trigger generic version notifier job on release (#12292) --- .github/workflows/release.yml | 53 +++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75d88abf2..96d119607 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -219,10 +219,19 @@ jobs: - init - deploy-manifest steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + with: + app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + owner: esphome + repositories: home-assistant-addon + - name: Trigger Workflow uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ secrets.DEPLOY_HA_ADDON_REPO_TOKEN }} + github-token: ${{ steps.generate-token.outputs.token }} script: | let description = "ESPHome"; if (context.eventName == "release") { @@ -245,10 +254,19 @@ jobs: needs: [init] environment: ${{ needs.init.outputs.deploy_env }} steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + with: + app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + owner: esphome + repositories: esphome-schema + - name: Trigger Workflow uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ secrets.DEPLOY_ESPHOME_SCHEMA_REPO_TOKEN }} + github-token: ${{ steps.generate-token.outputs.token }} script: | github.rest.actions.createWorkflowDispatch({ owner: "esphome", @@ -259,3 +277,34 @@ jobs: version: "${{ needs.init.outputs.tag }}", } }) + + version-notifier: + if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' + runs-on: ubuntu-latest + needs: + - init + - deploy-manifest + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + with: + app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + owner: esphome + repositories: version-notifier + + - name: Trigger Workflow + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + github.rest.actions.createWorkflowDispatch({ + owner: "esphome", + repo: "version-notifier", + workflow_id: "notify.yml", + ref: "main", + inputs: { + version: "${{ needs.init.outputs.tag }}", + } + }) From 7077488dc72131e7bd5c4946054ff2ec3b90c669 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Dec 2025 01:14:22 +0000 Subject: [PATCH 7/8] [scheduler] Fix use-after-free when cancelling timeouts from non-main-loop threads (#12288) --- esphome/core/scheduler.cpp | 33 ++++++++++++++------------------- esphome/core/scheduler.h | 8 +++++--- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 352587bf1..5e313f770 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -315,7 +315,7 @@ void Scheduler::full_cleanup_removed_items_() { valid_items.push_back(std::move(item)); } else { // Recycle removed items - this->recycle_item_(std::move(item)); + this->recycle_item_main_loop_(std::move(item)); } } @@ -400,7 +400,7 @@ void HOT Scheduler::call(uint32_t now) { // Don't run on failed components if (item->component != nullptr && item->component->is_failed()) { LockGuard guard{this->lock_}; - this->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); continue; } @@ -413,7 +413,7 @@ void HOT Scheduler::call(uint32_t now) { { LockGuard guard{this->lock_}; if (is_item_removed_(item.get())) { - this->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); this->to_remove_--; continue; } @@ -422,7 +422,7 @@ void HOT Scheduler::call(uint32_t now) { // Single-threaded or multi-threaded with atomics: can check without lock if (is_item_removed_(item.get())) { LockGuard guard{this->lock_}; - this->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); this->to_remove_--; continue; } @@ -449,7 +449,7 @@ void HOT Scheduler::call(uint32_t now) { if (executed_item->remove) { // We were removed/cancelled in the function call, recycle and continue this->to_remove_--; - this->recycle_item_(std::move(executed_item)); + this->recycle_item_main_loop_(std::move(executed_item)); continue; } @@ -460,7 +460,7 @@ void HOT Scheduler::call(uint32_t now) { this->to_add_.push_back(std::move(executed_item)); } else { // Timeout completed - recycle it - this->recycle_item_(std::move(executed_item)); + this->recycle_item_main_loop_(std::move(executed_item)); } has_added_items |= !this->to_add_.empty(); @@ -475,7 +475,7 @@ void HOT Scheduler::process_to_add() { for (auto &it : this->to_add_) { if (is_item_removed_(it.get())) { // Recycle cancelled items - this->recycle_item_(std::move(it)); + this->recycle_item_main_loop_(std::move(it)); continue; } @@ -509,7 +509,7 @@ size_t HOT Scheduler::cleanup_() { if (!item->remove) break; this->to_remove_--; - this->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); } return this->items_.size(); } @@ -562,20 +562,15 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c #endif /* not ESPHOME_THREAD_SINGLE */ // Cancel items in the main heap - // Special case: if the last item in the heap matches, we can remove it immediately - // (removing the last element doesn't break heap structure) + // We only mark items for removal here - never recycle directly. + // The main loop may be executing an item's callback right now, and recycling + // would destroy the callback while it's running (use-after-free). + // Only the main loop in call() should recycle items after execution completes. if (!this->items_.empty()) { - auto &last_item = this->items_.back(); - if (this->matches_item_locked_(last_item, component, name_cstr, type, match_retry)) { - this->recycle_item_(std::move(this->items_.back())); - this->items_.pop_back(); - total_cancelled++; - } - // For other items in heap, we can only mark for removal (can't remove from middle of heap) size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_cstr, type, match_retry); total_cancelled += heap_cancelled; - this->to_remove_ += heap_cancelled; // Track removals for heap items + this->to_remove_ += heap_cancelled; } // Cancel items in to_add_ @@ -749,7 +744,7 @@ bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr &a, : (a->next_execution_high_ > b->next_execution_high_); } -void Scheduler::recycle_item_(std::unique_ptr item) { +void Scheduler::recycle_item_main_loop_(std::unique_ptr item) { if (!item) return; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 08e003c9f..dcf418c14 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -272,8 +272,10 @@ class Scheduler { return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed()); } - // Helper to recycle a SchedulerItem - void recycle_item_(std::unique_ptr item); + // Helper to recycle a SchedulerItem back to the pool. + // IMPORTANT: Only call from main loop context! Recycling clears the callback, + // so calling from another thread while the callback is executing causes use-after-free. + void recycle_item_main_loop_(std::unique_ptr item); // Helper to perform full cleanup when too many items are cancelled void full_cleanup_removed_items_(); @@ -329,7 +331,7 @@ class Scheduler { now = this->execute_item_(item.get(), now); } // Recycle the defer item after execution - this->recycle_item_(std::move(item)); + this->recycle_item_main_loop_(std::move(item)); } // If we've consumed all items up to the snapshot point, clean up the dead space From 8f20abebf688feb47e63019ace17f8cee391df5b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:52:48 -0500 Subject: [PATCH 8/8] Bump version to 2025.11.4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 901b0c92c..dbb744767 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.11.3 +PROJECT_NUMBER = 2025.11.4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 10f0b3af4..472e0a7be 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.11.3" +__version__ = "2025.11.4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = (