From 48ba007c22d7c542e9de8740aacb8c94ac438a10 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 21 Feb 2026 20:16:20 +0100 Subject: [PATCH 1/6] [nrf52] print line number after crash in logs (#14165) --- esphome/__main__.py | 19 ++++++++++--- esphome/components/nrf52/__init__.py | 40 ++++++++++++++++++++++++++++ tests/unit_tests/test_main.py | 6 +++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 488955f503..ffedb90bde 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -431,6 +431,14 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: return 1 _LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate) + process_stacktrace = None + + try: + module = importlib.import_module("esphome.components." + CORE.target_platform) + process_stacktrace = getattr(module, "process_stacktrace") + except AttributeError: + pass + backtrace_state = False ser = serial.Serial() ser.baudrate = baud_rate @@ -472,9 +480,14 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: ) safe_print(parser.parse_line(line, time_str)) - backtrace_state = platformio_api.process_stacktrace( - config, line, backtrace_state=backtrace_state - ) + if process_stacktrace: + backtrace_state = process_stacktrace( + config, line, backtrace_state + ) + else: + backtrace_state = platformio_api.process_stacktrace( + config, line, backtrace_state=backtrace_state + ) except serial.SerialException: _LOGGER.error("Serial port closed!") return 0 diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 95e3670124..a12d1db1ab 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio import logging from pathlib import Path +import re +import subprocess from esphome import pins import esphome.codegen as cg @@ -380,3 +382,41 @@ def show_logs(config: ConfigType, args, devices: list[str]) -> bool: asyncio.run(logger_connect(address)) return True return False + + +def _addr2line(addr2line: str, elf: Path, addr: str) -> str: + try: + result = subprocess.run( + [addr2line, "-e", elf, addr], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip().splitlines()[0] + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Running command failed: %s", err) + return "" + + +def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool: + if "Last crash:" in line: + return True + if backtrace_state: + match = re.search(r"PC=(0x[0-9a-fA-F]+)\s+LR=(0x[0-9a-fA-F]+)", line) + if match: + pc = match.group(1) + lr = match.group(2) + from esphome.analyze_memory.toolchain import find_tool + + addr2line = find_tool("addr2line") + if addr2line is None: + return False + elf = CORE.relative_pioenvs_path(CORE.name, "firmware.elf") + if not elf.exists(): + _LOGGER.warning("%s does not exists", elf) + return False + _LOGGER.error("=== CRASH ===") + _LOGGER.error("PC: %s", _addr2line(addr2line, elf, pc)) + _LOGGER.error("LR: %s", _addr2line(addr2line, elf, lr)) + + return False diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index c9aa446323..cef561c54b 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -2951,6 +2951,7 @@ def test_run_miniterm_batches_lines_with_same_timestamp( mock_serial = MockSerial([chunk, MOCK_SERIAL_END]) + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} config = { CONF_LOGGER: { CONF_BAUD_RATE: 115200, @@ -2989,6 +2990,7 @@ def test_run_miniterm_different_chunks_different_timestamps( mock_serial = MockSerial([chunk1, chunk2, MOCK_SERIAL_END]) + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} config = { CONF_LOGGER: { CONF_BAUD_RATE: 115200, @@ -3019,6 +3021,7 @@ def test_run_miniterm_handles_split_lines() -> None: mock_serial = MockSerial([chunk1, chunk2, MOCK_SERIAL_END]) + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} config = { CONF_LOGGER: { CONF_BAUD_RATE: 115200, @@ -3057,6 +3060,7 @@ def test_run_miniterm_backtrace_state_maintained() -> None: mock_serial = MockSerial([backtrace_chunk, MOCK_SERIAL_END]) + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} config = { CONF_LOGGER: { CONF_BAUD_RATE: 115200, @@ -3122,6 +3126,7 @@ def test_run_miniterm_handles_empty_reads( mock_serial = MockSerial([b"", chunk, b"", MOCK_SERIAL_END]) + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} config = { CONF_LOGGER: { CONF_BAUD_RATE: 115200, @@ -3194,6 +3199,7 @@ def test_run_miniterm_buffer_limit_prevents_unbounded_growth() -> None: mock_serial = MockSerial([large_data_no_newline, final_line, MOCK_SERIAL_END]) + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} config = { CONF_LOGGER: { CONF_BAUD_RATE: 115200, From 9571a979eb7ff1d881a4a833a639ff54c861174e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 13:53:45 -0600 Subject: [PATCH 2/6] [ci] Suggest StringRef instead of std::string_view (#14183) --- script/ci-custom.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/script/ci-custom.py b/script/ci-custom.py index 231f587068..df819e0f04 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -494,6 +494,22 @@ def lint_no_byte_datatype(fname, match): ) +@lint_re_check( + r"(?:std\s*::\s*string_view|#include\s*)" + CPP_RE_EOL, + include=cpp_include, +) +def lint_no_std_string_view(fname, match): + return ( + f"{highlight('std::string_view')} is not allowed in ESPHome. " + f"It pulls in significant STL template machinery that bloats flash on " + f"resource-constrained embedded targets, does not work well with ArduinoJson, " + f"and duplicates functionality already provided by {highlight('StringRef')}.\n" + f"Please use {highlight('StringRef')} from {highlight('esphome/core/string_ref.h')} " + f"for non-owning string references, or {highlight('const char *')} for simple cases.\n" + f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)" + ) + + @lint_post_check def lint_constants_usage(): errs = [] From 5a07908dfa9ef499b20e8a53f23e5ea94b9eb682 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 13:54:20 -0600 Subject: [PATCH 3/6] [api] Fix build error when lambda returns StringRef in homeassistant.event data (#14187) --- esphome/components/api/homeassistant_service.h | 2 ++ tests/components/homeassistant/common.yaml | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 2322d96eef..340699e1a6 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -36,6 +36,8 @@ template class TemplatableStringValue : public TemplatableValue() {} diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 9c6cb71b8b..60e3defd49 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -90,6 +90,19 @@ text_sensor: id: ha_hello_world_text2 attribute: some_attribute +event: + - platform: template + name: Test Event + id: test_event + event_types: + - test_event_type + on_event: + - homeassistant.event: + event: esphome.test_event + data: + event_name: !lambda |- + return event_type; + time: - platform: homeassistant on_time: From e521522b388dce7066ade34d96869187e7c47ca6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 13:54:43 -0600 Subject: [PATCH 4/6] [haier] Fix uninitialized HonSettings causing API connection failures (#14188) --- esphome/components/haier/hon_climate.h | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index a567ab1d89..608d5e7f21 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -29,10 +29,10 @@ enum class CleaningState : uint8_t { enum class HonControlMethod { MONITOR_ONLY = 0, SET_GROUP_PARAMETERS, SET_SINGLE_PARAMETER }; struct HonSettings { - hon_protocol::VerticalSwingMode last_vertiacal_swing; - hon_protocol::HorizontalSwingMode last_horizontal_swing; - bool beeper_state; - bool quiet_mode_state; + hon_protocol::VerticalSwingMode last_vertiacal_swing{hon_protocol::VerticalSwingMode::CENTER}; + hon_protocol::HorizontalSwingMode last_horizontal_swing{hon_protocol::HorizontalSwingMode::CENTER}; + bool beeper_state{true}; + bool quiet_mode_state{false}; }; class HonClimate : public HaierClimateBase { @@ -189,7 +189,7 @@ class HonClimate : public HaierClimateBase { int big_data_sensors_{0}; esphome::optional current_vertical_swing_{}; esphome::optional current_horizontal_swing_{}; - HonSettings settings_; + HonSettings settings_{}; ESPPreferenceObject hon_rtc_; SwitchState quiet_mode_state_{SwitchState::OFF}; }; From 6f198adb0c79bcc8e14cdb07cbf484bb23aa1e52 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 14:29:50 -0600 Subject: [PATCH 5/6] [scheduler] Reduce lock acquisitions in process_defer_queue_ (#14107) --- esphome/core/scheduler.cpp | 23 +++++++++++ esphome/core/scheduler.h | 79 ++++++++++++++++++-------------------- 2 files changed, 60 insertions(+), 42 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index e82efcc520..b810df7e1c 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -421,6 +421,29 @@ void Scheduler::full_cleanup_removed_items_() { this->to_remove_ = 0; } +#ifndef ESPHOME_THREAD_SINGLE +void Scheduler::compact_defer_queue_locked_() { + // Rare case: new items were added during processing - compact the vector + // This only happens when: + // 1. A deferred callback calls defer() again, or + // 2. Another thread calls defer() while we're processing + // + // Move unprocessed items (added during this loop) to the front for next iteration + // + // SAFETY: Compacted items may include cancelled items (marked for removal via + // cancel_item_locked_() during execution). This is safe because should_skip_item_() + // checks is_item_removed_() before executing, so cancelled items will be skipped + // and recycled on the next loop iteration. + size_t remaining = this->defer_queue_.size() - this->defer_queue_front_; + for (size_t i = 0; i < remaining; i++) { + this->defer_queue_[i] = std::move(this->defer_queue_[this->defer_queue_front_ + i]); + } + // Use erase() instead of resize() to avoid instantiating _M_default_append + // (saves ~156 bytes flash). Erasing from the end is O(1) - no shifting needed. + this->defer_queue_.erase(this->defer_queue_.begin() + remaining, this->defer_queue_.end()); +} +#endif /* not ESPHOME_THREAD_SINGLE */ + void HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index afe11aaca6..041c6f4687 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -387,41 +387,46 @@ class Scheduler { // No lock needed: single consumer (main loop), stale read just means we process less this iteration size_t defer_queue_end = this->defer_queue_.size(); + // Fast path: nothing to process, avoid lock entirely. + // Safe without lock: single consumer (main loop) reads front_, and a stale size() read + // from a concurrent push can only make us see fewer items — they'll be processed next loop. + if (this->defer_queue_front_ >= defer_queue_end) + return; + + // Merge lock acquisitions: instead of separate locks for move-out and recycle (2N+1 total), + // recycle each item after re-acquiring the lock for the next iteration (N+1 total). + // The lock is held across: recycle → loop condition → move-out, then released for execution. + std::unique_ptr item; + + this->lock_.lock(); while (this->defer_queue_front_ < defer_queue_end) { - std::unique_ptr item; - { - LockGuard lock(this->lock_); - // SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_. - // This is intentional and safe because: - // 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function - // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_ - // and has_cancelled_timeout_in_container_locked_ in scheduler.h) - // 3. The lock protects concurrent access, but the nullptr remains until cleanup - item = std::move(this->defer_queue_[this->defer_queue_front_]); - this->defer_queue_front_++; - } + // SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_. + // This is intentional and safe because: + // 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function + // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_ + // and has_cancelled_timeout_in_container_locked_ in scheduler.h) + // 3. The lock protects concurrent access, but the nullptr remains until cleanup + item = std::move(this->defer_queue_[this->defer_queue_front_]); + this->defer_queue_front_++; + this->lock_.unlock(); // Execute callback without holding lock to prevent deadlocks // if the callback tries to call defer() again if (!this->should_skip_item_(item.get())) { now = this->execute_item_(item.get(), now); } - // Recycle the defer item after execution - { - LockGuard lock(this->lock_); - this->recycle_item_main_loop_(std::move(item)); - } - } - // If we've consumed all items up to the snapshot point, clean up the dead space - // Single consumer (main loop), so no lock needed for this check - if (this->defer_queue_front_ >= defer_queue_end) { - LockGuard lock(this->lock_); - this->cleanup_defer_queue_locked_(); + this->lock_.lock(); + this->recycle_item_main_loop_(std::move(item)); } + // Clean up the queue (lock already held from last recycle or initial acquisition) + this->cleanup_defer_queue_locked_(); + this->lock_.unlock(); } - // Helper to cleanup defer_queue_ after processing + // Helper to cleanup defer_queue_ after processing. + // Keeps the common clear() path inline, outlines the rare compaction to keep + // cold code out of the hot instruction cache lines. // IMPORTANT: Caller must hold the scheduler lock before calling this function. inline void cleanup_defer_queue_locked_() { // Check if new items were added by producers during processing @@ -429,27 +434,17 @@ class Scheduler { // Common case: no new items - clear everything this->defer_queue_.clear(); } else { - // Rare case: new items were added during processing - compact the vector - // This only happens when: - // 1. A deferred callback calls defer() again, or - // 2. Another thread calls defer() while we're processing - // - // Move unprocessed items (added during this loop) to the front for next iteration - // - // SAFETY: Compacted items may include cancelled items (marked for removal via - // cancel_item_locked_() during execution). This is safe because should_skip_item_() - // checks is_item_removed_() before executing, so cancelled items will be skipped - // and recycled on the next loop iteration. - size_t remaining = this->defer_queue_.size() - this->defer_queue_front_; - for (size_t i = 0; i < remaining; i++) { - this->defer_queue_[i] = std::move(this->defer_queue_[this->defer_queue_front_ + i]); - } - // Use erase() instead of resize() to avoid instantiating _M_default_append - // (saves ~156 bytes flash). Erasing from the end is O(1) - no shifting needed. - this->defer_queue_.erase(this->defer_queue_.begin() + remaining, this->defer_queue_.end()); + // Rare case: new items were added during processing - outlined to keep cold code + // out of the hot instruction cache lines + this->compact_defer_queue_locked_(); } this->defer_queue_front_ = 0; } + + // Cold path for compacting defer_queue_ when new items were added during processing. + // IMPORTANT: Caller must hold the scheduler lock before calling this function. + // IMPORTANT: Must not be inlined - rare path, outlined to keep it out of the hot instruction cache lines. + void __attribute__((noinline)) compact_defer_queue_locked_(); #endif /* not ESPHOME_THREAD_SINGLE */ // Helper to check if item is marked for removal (platform-specific) From 462ac2956312f21e506b1e2e13a6960e18d0d813 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 17:29:41 -0600 Subject: [PATCH 6/6] [scheduler] Use relaxed memory ordering for atomic reads under lock (#14140) --- esphome/core/scheduler.cpp | 10 +++++----- esphome/core/scheduler.h | 18 +++++++++++++++--- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index b810df7e1c..36b65f6ff7 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -406,7 +406,7 @@ void Scheduler::full_cleanup_removed_items_() { // Compact in-place: move valid items forward, recycle removed ones size_t write = 0; for (size_t read = 0; read < this->items_.size(); ++read) { - if (!is_item_removed_(this->items_[read].get())) { + if (!is_item_removed_locked_(this->items_[read].get())) { if (write != read) { this->items_[write] = std::move(this->items_[read]); } @@ -531,7 +531,7 @@ void HOT Scheduler::call(uint32_t now) { // Multi-threaded platforms without atomics: must take lock to safely read remove flag { LockGuard guard{this->lock_}; - if (is_item_removed_(item.get())) { + if (is_item_removed_locked_(item.get())) { this->recycle_item_main_loop_(this->pop_raw_locked_()); this->to_remove_--; continue; @@ -568,7 +568,7 @@ void HOT Scheduler::call(uint32_t now) { // during the function call and know if we were cancelled. auto executed_item = this->pop_raw_locked_(); - if (executed_item->remove) { + if (this->is_item_removed_locked_(executed_item.get())) { // We were removed/cancelled in the function call, recycle and continue this->to_remove_--; this->recycle_item_main_loop_(std::move(executed_item)); @@ -595,7 +595,7 @@ void HOT Scheduler::call(uint32_t now) { void HOT Scheduler::process_to_add() { LockGuard guard{this->lock_}; for (auto &it : this->to_add_) { - if (is_item_removed_(it.get())) { + if (is_item_removed_locked_(it.get())) { // Recycle cancelled items this->recycle_item_main_loop_(std::move(it)); continue; @@ -628,7 +628,7 @@ size_t HOT Scheduler::cleanup_() { LockGuard guard{this->lock_}; while (!this->items_.empty()) { auto &item = this->items_[0]; - if (!item->remove) + if (!this->is_item_removed_locked_(item.get())) break; this->to_remove_--; this->recycle_item_main_loop_(this->pop_raw_locked_()); diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 041c6f4687..384d76b6b0 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -314,8 +314,8 @@ class Scheduler { // Fixes: https://github.com/esphome/esphome/issues/11940 if (!item) return false; - if (item->component != component || item->type != type || (skip_removed && item->remove) || - (match_retry && !item->is_retry)) { + if (item->component != component || item->type != type || + (skip_removed && this->is_item_removed_locked_(item.get())) || (match_retry && !item->is_retry)) { return false; } // Name type must match @@ -463,6 +463,18 @@ class Scheduler { #endif } + // Helper to check if item is marked for removal when lock is already held. + // Uses relaxed ordering since the mutex provides all necessary synchronization. + // IMPORTANT: Caller must hold the scheduler lock before calling this function. + bool is_item_removed_locked_(SchedulerItem *item) const { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // Lock already held - relaxed is sufficient, mutex provides ordering + return item->remove.load(std::memory_order_relaxed); +#else + return item->remove; +#endif + } + // Helper to set item removal flag (platform-specific) // For ESPHOME_THREAD_MULTI_NO_ATOMICS platforms, the caller must hold the scheduler lock before calling this // function. Uses memory_order_release when setting to true (for cancellation synchronization), @@ -519,7 +531,7 @@ class Scheduler { // it will iterate over these nullptr items. This check prevents crashes. if (!item) continue; - if (is_item_removed_(item.get()) && + if (this->is_item_removed_locked_(item.get()) && this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT, match_retry, /* skip_removed= */ false)) { return true;