From 02f7aee680bb0a7b7a7a54e1b2cd759f8876d77c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:34:53 +1300 Subject: [PATCH 1/5] Bump version to 2026.3.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 1a9e0b4e10f..9f5cd0a2ce3 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 = 2026.3.0-dev +PROJECT_NUMBER = 2026.3.0b1 # 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 d409514f3c7..eb49c9a1d77 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.3.0-dev" +__version__ = "2026.3.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 409640c0ee441d683b2939c2235f2bee184aecaf Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:30:44 -0400 Subject: [PATCH 2/5] [esp32_hosted] Bump esp_hosted to 2.12.1 (#14708) Co-authored-by: Claude Opus 4.6 --- esphome/components/esp32_hosted/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 6d49053d6d5..a51ae2cd666 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -105,7 +105,7 @@ async def to_code(config): if framework_ver >= cv.Version(5, 5, 0): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.4.0") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.4") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.0") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.1") 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") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index acd7f7a4798..f7fd3e67bc3 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -20,7 +20,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.0 + version: 2.12.1 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From ddc40f44fa81f41ad8970e1e607d50d646e8f2cc Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 11 Mar 2026 19:56:25 -0500 Subject: [PATCH 3/5] [ethernet] ESP32-P4 Ethernet compilation fix (#14714) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/ethernet/ethernet_component.cpp | 18 +----------------- .../components/ethernet/ethernet_component.h | 2 ++ esphome/components/ethernet/ethernet_helpers.c | 8 ++++++++ .../components/ethernet/test.esp32-p4-idf.yaml | 1 + 4 files changed, 12 insertions(+), 17 deletions(-) create mode 100644 esphome/components/ethernet/ethernet_helpers.c create mode 100644 tests/components/ethernet/test.esp32-p4-idf.yaml diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index d6b0d40cd95..e0788e11498 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -21,22 +21,6 @@ namespace esphome::ethernet { -#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) -// work around IDF compile issue on P4 https://github.com/espressif/esp-idf/pull/15637 -#ifdef USE_ESP32_VARIANT_ESP32P4 -#undef ETH_ESP32_EMAC_DEFAULT_CONFIG -#define ETH_ESP32_EMAC_DEFAULT_CONFIG() \ - { \ - .smi_gpio = {.mdc_num = 31, .mdio_num = 52}, .interface = EMAC_DATA_INTERFACE_RMII, \ - .clock_config = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) 50}}, \ - .dma_burst_len = ETH_DMA_BURST_LEN_32, .intr_priority = 0, \ - .emac_dataif_gpio = \ - {.rmii = {.tx_en_num = 49, .txd0_num = 34, .txd1_num = 35, .crs_dv_num = 28, .rxd0_num = 29, .rxd1_num = 30}}, \ - .clock_config_out_in = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) -1}}, \ - } -#endif -#endif - static const char *const TAG = "ethernet"; // PHY register size for hex logging @@ -162,7 +146,7 @@ void EthernetComponent::setup() { phy_config.phy_addr = this->phy_addr_; phy_config.reset_gpio_num = this->power_pin_; - eth_esp32_emac_config_t esp32_emac_config = ETH_ESP32_EMAC_DEFAULT_CONFIG(); + eth_esp32_emac_config_t esp32_emac_config = eth_esp32_emac_default_config(); #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) esp32_emac_config.smi_gpio.mdc_num = this->mdc_pin_; esp32_emac_config.smi_gpio.mdio_num = this->mdio_pin_; diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index d9f05be9de0..c464e20b843 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -15,6 +15,8 @@ #include "esp_mac.h" #include "esp_idf_version.h" +extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); + namespace esphome::ethernet { #ifdef USE_ETHERNET_IP_STATE_LISTENERS diff --git a/esphome/components/ethernet/ethernet_helpers.c b/esphome/components/ethernet/ethernet_helpers.c new file mode 100644 index 00000000000..96faccad24c --- /dev/null +++ b/esphome/components/ethernet/ethernet_helpers.c @@ -0,0 +1,8 @@ +#include "esp_eth_mac_esp.h" + +// ETH_ESP32_EMAC_DEFAULT_CONFIG() uses out-of-order designated initializers +// which are valid in C but not in C++. This wrapper allows C++ code to get +// the default config without replicating the macro's contents. +eth_esp32_emac_config_t eth_esp32_emac_default_config(void) { + return (eth_esp32_emac_config_t) ETH_ESP32_EMAC_DEFAULT_CONFIG(); +} diff --git a/tests/components/ethernet/test.esp32-p4-idf.yaml b/tests/components/ethernet/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..e52329d7ea2 --- /dev/null +++ b/tests/components/ethernet/test.esp32-p4-idf.yaml @@ -0,0 +1 @@ +<<: !include common-ip101.yaml From 8daa946afa37e92500809cd0ad60e1fd429cb3ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 15:00:20 -1000 Subject: [PATCH 4/5] [esp32] Add crash handler to capture and report backtrace across reboots (#14709) --- esphome/components/api/api_connection.h | 6 + esphome/components/api/client.py | 21 ++ esphome/components/esp32/__init__.py | 5 + esphome/components/esp32/core.cpp | 6 + esphome/components/esp32/crash_handler.cpp | 355 +++++++++++++++++++++ esphome/components/esp32/crash_handler.h | 18 ++ esphome/components/logger/logger_esp32.cpp | 4 + esphome/core/defines.h | 1 + esphome/platformio_api.py | 7 + tests/unit_tests/test_platformio_api.py | 28 ++ 10 files changed, 451 insertions(+) create mode 100644 esphome/components/esp32/crash_handler.cpp create mode 100644 esphome/components/esp32/crash_handler.h diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 3356511684f..60cc3e91b11 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -14,6 +14,9 @@ #include "api_server.h" #include "esphome/core/application.h" #include "esphome/core/component.h" +#ifdef USE_ESP32_CRASH_HANDLER +#include "esphome/components/esp32/crash_handler.h" +#endif #include "esphome/core/entity_base.h" #include "esphome/core/string_ref.h" @@ -235,6 +238,9 @@ class APIConnection final : public APIServerConnectionBase { this->flags_.log_subscription = msg.level; if (msg.dump_config) App.schedule_dump_config(); +#ifdef USE_ESP32_CRASH_HANDLER + esp32::crash_handler_log(); +#endif } #ifdef USE_API_HOMEASSISTANT_SERVICES void on_subscribe_homeassistant_services_request() override { this->flags_.service_call_subscription = true; } diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 200d0938bd5..0e71ad8fcbf 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio from datetime import datetime +import importlib import logging from typing import TYPE_CHECKING, Any import warnings @@ -18,6 +19,7 @@ import contextlib from esphome.const import CONF_KEY, CONF_PORT, __version__ from esphome.core import CORE +from esphome.platformio_api import process_stacktrace from . import CONF_ENCRYPTION @@ -55,9 +57,19 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: addresses=addresses, # Pass all addresses for automatic retry ) dashboard = CORE.dashboard + backtrace_state = False + + # Try platform-specific stacktrace handler first, fall back to generic + platform_process_stacktrace = None + try: + module = importlib.import_module("esphome.components." + CORE.target_platform) + platform_process_stacktrace = getattr(module, "process_stacktrace") + except (AttributeError, ImportError): + pass def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" + nonlocal backtrace_state time_ = datetime.now() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") @@ -67,6 +79,15 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: ) for parsed_msg in parse_log_message(text, timestamp): print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) + for raw_line in text.splitlines(): + if platform_process_stacktrace: + backtrace_state = platform_process_stacktrace( + config, raw_line, backtrace_state + ) + else: + backtrace_state = process_stacktrace( + config, raw_line, backtrace_state=backtrace_state + ) stop = await async_run(cli, on_log, name=name) try: diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 52e70501dcf..475de6aa3e4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1442,6 +1442,11 @@ async def to_code(config): cg.add_build_flag("-DUSE_ESP32") cg.add_define("USE_NATIVE_64BIT_TIME") cg.add_build_flag("-Wl,-z,noexecstack") + # Arduino already wraps esp_panic_handler for its own backtrace handler, + # so only add our wrap when using ESP-IDF framework to avoid linker conflicts. + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: + cg.add_build_flag("-Wl,--wrap=esp_panic_handler") + cg.add_define("USE_ESP32_CRASH_HANDLER") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 46c000562e1..cba25bca2b2 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "esphome/core/defines.h" +#include "crash_handler.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "preferences.h" @@ -36,6 +37,11 @@ void arch_restart() { } void arch_init() { +#ifdef USE_ESP32_CRASH_HANDLER + // Read crash data from previous boot before anything else + esp32::crash_handler_read_and_clear(); +#endif + // Enable the task watchdog only on the loop task (from which we're currently running) esp_task_wdt_add(nullptr); diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp new file mode 100644 index 00000000000..ecf30d78781 --- /dev/null +++ b/esphome/components/esp32/crash_handler.cpp @@ -0,0 +1,355 @@ +#ifdef USE_ESP32 + +#include "esphome/core/defines.h" +#ifdef USE_ESP32_CRASH_HANDLER + +#include "crash_handler.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include + +#if CONFIG_IDF_TARGET_ARCH_XTENSA +#include +#include +#include +#elif CONFIG_IDF_TARGET_ARCH_RISCV +#include +#endif + +static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF; +static constexpr size_t MAX_BACKTRACE = 16; + +// Check if an address looks like code (flash-mapped or IRAM). +// Must be safe to call from panic context (no flash access needed). +static inline bool IRAM_ATTR is_code_addr(uint32_t addr) { + return (addr >= SOC_IROM_LOW && addr < SOC_IROM_HIGH) || (addr >= SOC_IRAM_LOW && addr < SOC_IRAM_HIGH); +} + +#if CONFIG_IDF_TARGET_ARCH_RISCV +// Check if a code address is a real return address by verifying the preceding +// instruction is a JAL or JALR with rd=ra (x1). Called at log time (not during +// panic) so flash cache is available and both IRAM and IROM are safely readable. +static inline bool is_return_addr(uint32_t addr) { + if (!is_code_addr(addr) || addr < 4) + return false; + // A return address on the stack points to the instruction after a call. + // Check for 4-byte JAL/JALR call instruction before this address. + // Use memcpy for alignment safety — RISC-V C extension means code addresses + // are only 2-byte aligned, so addr-4 may not be 4-byte aligned. + uint32_t inst; + memcpy(&inst, (const void *) (addr - 4), sizeof(inst)); + // RISC-V instruction encoding: bits [6:0] = opcode, bits [11:7] = rd + uint32_t opcode = inst & 0x7f; // Extract 7-bit opcode + uint32_t rd = inst & 0xf80; // Extract rd field (bits 11:7) + // Match JAL (0x6f) or JALR (0x67) with rd=ra (x1, encoded as 0x80 = 1<<7) + if ((opcode == 0x6f || opcode == 0x67) && rd == 0x80) + return true; + // Check for 2-byte compressed c.jalr before this address (C extension). + // c.jalr saves to ra implicitly: funct4=1001, rs1!=0, rs2=0, op=10 + if (addr >= 2) { + uint16_t c_inst = *(uint16_t *) (addr - 2); + if ((c_inst & 0xf07f) == 0x9002 && (c_inst & 0x0f80) != 0) + return true; + } + return false; +} +#endif + +// Raw crash data written by the panic handler wrapper. +// Lives in .noinit so it survives software reset but contains garbage after power cycle. +// Validated by magic marker. Static linkage since it's only used within this file. +// Version field is first so future firmware can always identify the struct layout. +// Magic is second to validate the data. Remaining fields can change between versions. +// Version is uint32_t because it would be padded to 4 bytes anyway before the next +// uint32_t field, so we use the full width rather than wasting 3 bytes of padding. +static constexpr uint32_t CRASH_DATA_VERSION = 1; +struct RawCrashData { + uint32_t version; + uint32_t magic; + uint32_t pc; + uint8_t backtrace_count; + uint8_t reg_frame_count; // Number of entries from registers (not stack-scanned) + uint8_t exception; // panic_exception_t enum (FAULT/ABORT/IWDT/TWDT/DEBUG) + uint8_t pseudo_excause; // Whether cause is a pseudo exception (Xtensa SoC-level panic) + uint32_t backtrace[MAX_BACKTRACE]; + uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) +}; +static RawCrashData __attribute__((section(".noinit"))) +s_raw_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// Whether crash data was found and validated this boot. +static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +namespace esphome::esp32 { + +static const char *const TAG = "esp32.crash"; + +void crash_handler_read_and_clear() { + if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { + s_crash_data_valid = true; + // Clamp counts to prevent out-of-bounds reads from corrupt .noinit data + if (s_raw_crash_data.backtrace_count > MAX_BACKTRACE) + s_raw_crash_data.backtrace_count = MAX_BACKTRACE; + if (s_raw_crash_data.reg_frame_count > s_raw_crash_data.backtrace_count) + s_raw_crash_data.reg_frame_count = s_raw_crash_data.backtrace_count; + if (s_raw_crash_data.exception > 4) // panic_exception_t max value + s_raw_crash_data.exception = 4; // Default to PANIC_EXCEPTION_FAULT + if (s_raw_crash_data.pseudo_excause > 1) + s_raw_crash_data.pseudo_excause = 0; + } + // Clear magic regardless so we don't re-report on next normal reboot + s_raw_crash_data.magic = 0; +} + +bool crash_handler_has_data() { return s_crash_data_valid; } + +// Look up the exception cause as a human-readable string. +// Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays +// not exposed via any public API. +static const char *get_exception_reason() { +#if CONFIG_IDF_TARGET_ARCH_XTENSA + if (s_raw_crash_data.pseudo_excause) { + // SoC-level panic: watchdog, cache error, etc. + // Keep in sync with ESP-IDF's PANIC_RSN_* defines + static const char *const PSEUDO_REASON[] = { + "Unknown reason", // 0 + "Unhandled debug exception", // 1 + "Double exception", // 2 + "Unhandled kernel exception", // 3 + "Coprocessor exception", // 4 + "Interrupt wdt timeout on CPU0", // 5 + "Interrupt wdt timeout on CPU1", // 6 + "Cache error", // 7 + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(PSEUDO_REASON) / sizeof(PSEUDO_REASON[0])) + return PSEUDO_REASON[cause]; + return PSEUDO_REASON[0]; + } + // Real Xtensa exception + static const char *const REASON[] = { + "IllegalInstruction", + "Syscall", + "InstructionFetchError", + "LoadStoreError", + "Level1Interrupt", + "Alloca", + "IntegerDivideByZero", + "PCValue", + "Privileged", + "LoadStoreAlignment", + nullptr, + nullptr, + "InstrPDAddrError", + "LoadStorePIFDataError", + "InstrPIFAddrError", + "LoadStorePIFAddrError", + "InstTLBMiss", + "InstTLBMultiHit", + "InstFetchPrivilege", + nullptr, + "InstrFetchProhibited", + nullptr, + nullptr, + nullptr, + "LoadStoreTLBMiss", + "LoadStoreTLBMultihit", + "LoadStorePrivilege", + nullptr, + "LoadProhibited", + "StoreProhibited", + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) + return REASON[cause]; +#elif CONFIG_IDF_TARGET_ARCH_RISCV + // For SoC-level panics (watchdog, cache error), mcause holds IDF-internal + // interrupt numbers, not standard RISC-V cause codes. The exception type + // field already identifies these, so just return null to use the type name. + if (s_raw_crash_data.pseudo_excause) + return nullptr; + static const char *const REASON[] = { + "Instruction address misaligned", + "Instruction access fault", + "Illegal instruction", + "Breakpoint", + "Load address misaligned", + "Load access fault", + "Store address misaligned", + "Store access fault", + "Environment call from U-mode", + "Environment call from S-mode", + nullptr, + "Environment call from M-mode", + "Instruction page fault", + "Load page fault", + nullptr, + "Store page fault", + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) + return REASON[cause]; +#endif + return "Unknown"; +} + +// Exception type names matching panic_exception_t enum +static const char *get_exception_type() { + static const char *const TYPES[] = { + "Debug exception", // PANIC_EXCEPTION_DEBUG + "Interrupt wdt", // PANIC_EXCEPTION_IWDT + "Task wdt", // PANIC_EXCEPTION_TWDT + "Abort", // PANIC_EXCEPTION_ABORT + "Fault", // PANIC_EXCEPTION_FAULT + }; + uint8_t exc = s_raw_crash_data.exception; + if (exc < sizeof(TYPES) / sizeof(TYPES[0])) + return TYPES[exc]; + return "Unknown"; +} + +// Intentionally uses separate ESP_LOGE calls per line instead of combining into +// one multi-line log message. This ensures each address appears as its own line +// on the serial console, making it possible to see partial output if the device +// crashes again during boot, and allowing the CLI's process_stacktrace to match +// and decode each address individually. +void crash_handler_log() { + if (!s_crash_data_valid) + return; + + ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); + const char *reason = get_exception_reason(); + if (reason != nullptr) { + ESP_LOGE(TAG, " Reason: %s - %s", get_exception_type(), reason); + } else { + ESP_LOGE(TAG, " Reason: %s", get_exception_type()); + } + ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc); + uint8_t bt_num = 0; + for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count; i++) { + uint32_t addr = s_raw_crash_data.backtrace[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + // Register-sourced entries (MEPC/RA) are trusted; only filter stack-scanned ones. + if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) + continue; +#endif +#if CONFIG_IDF_TARGET_ARCH_RISCV + const char *source = (i < s_raw_crash_data.reg_frame_count) ? "backtrace" : "stack scan"; +#else + const char *source = "backtrace"; +#endif + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (%s)", bt_num++, addr, source); + } + // Build addr2line hint with all captured addresses for easy copy-paste + char hint[256]; + int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); + for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count && pos < (int) sizeof(hint) - 12; i++) { + uint32_t addr = s_raw_crash_data.backtrace[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) + continue; +#endif + pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, addr); + } + ESP_LOGE(TAG, "%s", hint); +} + +} // namespace esphome::esp32 + +// --- Panic handler wrapper --- +// Intercepts esp_panic_handler() via --wrap linker flag to capture crash data +// into NOINIT memory before the normal panic handler runs. +// +extern "C" { +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +// Names are mandated by the --wrap linker mechanism +extern void __real_esp_panic_handler(panic_info_t *info); + +void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { + // Save the faulting PC and exception info + s_raw_crash_data.pc = (uint32_t) info->addr; + s_raw_crash_data.backtrace_count = 0; + s_raw_crash_data.reg_frame_count = 0; + s_raw_crash_data.exception = (uint8_t) info->exception; + s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0; + +#if CONFIG_IDF_TARGET_ARCH_XTENSA + // Xtensa: walk the backtrace using the public API + if (info->frame != nullptr) { + auto *xt_frame = (XtExcFrame *) info->frame; + s_raw_crash_data.cause = xt_frame->exccause; + esp_backtrace_frame_t bt_frame = { + .pc = (uint32_t) xt_frame->pc, + .sp = (uint32_t) xt_frame->a1, + .next_pc = (uint32_t) xt_frame->a0, + .exc_frame = xt_frame, + }; + + uint8_t count = 0; + // First frame PC + uint32_t first_pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(first_pc)) { + s_raw_crash_data.backtrace[count++] = first_pc; + } + // Walk remaining frames + while (count < MAX_BACKTRACE && bt_frame.next_pc != 0) { + if (!esp_backtrace_get_next_frame(&bt_frame)) { + break; + } + uint32_t pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(pc)) { + s_raw_crash_data.backtrace[count++] = pc; + } + } + s_raw_crash_data.backtrace_count = count; + } + +#elif CONFIG_IDF_TARGET_ARCH_RISCV + // RISC-V: capture MEPC + RA, then scan stack for code addresses + if (info->frame != nullptr) { + auto *rv_frame = (RvExcFrame *) info->frame; + s_raw_crash_data.cause = rv_frame->mcause; + uint8_t count = 0; + + // Save MEPC (fault PC) and RA (return address) + if (is_code_addr(rv_frame->mepc)) { + s_raw_crash_data.backtrace[count++] = rv_frame->mepc; + } + if (is_code_addr(rv_frame->ra) && rv_frame->ra != rv_frame->mepc) { + s_raw_crash_data.backtrace[count++] = rv_frame->ra; + } + + // Track how many entries came from registers (MEPC/RA) so we can + // skip return-address validation for them at log time. + s_raw_crash_data.reg_frame_count = count; + + // Scan stack for code addresses — captures broadly during panic, + // filtered by is_return_addr() at log time when flash is accessible. + auto *scan_start = (uint32_t *) rv_frame->sp; + for (uint32_t i = 0; i < 64 && count < MAX_BACKTRACE; i++) { + uint32_t val = scan_start[i]; + if (is_code_addr(val) && val != rv_frame->mepc && val != rv_frame->ra) { + s_raw_crash_data.backtrace[count++] = val; + } + } + s_raw_crash_data.backtrace_count = count; + } +#endif + + // Write version and magic last — ensures all data is written before we mark it valid + s_raw_crash_data.version = CRASH_DATA_VERSION; + s_raw_crash_data.magic = CRASH_MAGIC; + + // Call the real panic handler (prints to UART, does core dump, reboots, etc.) + __real_esp_panic_handler(info); +} + +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +} // extern "C" + +#endif // USE_ESP32_CRASH_HANDLER +#endif // USE_ESP32 diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h new file mode 100644 index 00000000000..97a4d4e1162 --- /dev/null +++ b/esphome/components/esp32/crash_handler.h @@ -0,0 +1,18 @@ +#pragma once + +#ifdef USE_ESP32_CRASH_HANDLER + +namespace esphome::esp32 { + +/// Read crash data from NOINIT memory and clear the magic marker. +void crash_handler_read_and_clear(); + +/// Log crash data if a crash was detected on previous boot. +void crash_handler_log(); + +/// Returns true if crash data was found this boot. +bool crash_handler_has_data(); + +} // namespace esphome::esp32 + +#endif // USE_ESP32_CRASH_HANDLER diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index d6ad77ff4fa..f5bf7822899 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "logger.h" +#include "esphome/components/esp32/crash_handler.h" #include #include @@ -117,6 +118,9 @@ void Logger::pre_setup() { esp_log_set_vprintf(esp_idf_log_vprintf_); ESP_LOGI(TAG, "Log initialized"); +#ifdef USE_ESP32_CRASH_HANDLER + esp32::crash_handler_log(); +#endif } void HOT Logger::write_msg_(const char *msg, uint16_t len) { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index cec77fe2e27..a33f10cb9c0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -195,6 +195,7 @@ // ESP32-specific feature flags #ifdef USE_ESP32 +#define USE_ESP32_CRASH_HANDLER #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER #define USE_OTA_ROLLBACK diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index 5d4065207f0..cb080b2a953 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -340,6 +340,8 @@ STACKTRACE_ESP32_BACKTRACE_RE = re.compile( r"Backtrace:(?:\s*0x[0-9a-fA-F]{8}:0x[0-9a-fA-F]{8})+" ) STACKTRACE_ESP32_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") +# ESP32 crash handler (stored backtrace from previous boot) +STACKTRACE_ESP32_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") @@ -371,6 +373,11 @@ def process_stacktrace(config, line, backtrace_state): ) _decode_pc(config, match.group(1)) + # ESP32 crash handler backtrace (from previous boot) + match = re.search(STACKTRACE_ESP32_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # ESP32 single-line backtrace match = re.match(STACKTRACE_ESP32_BACKTRACE_RE, line) if match is not None: diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index 16861442777..e1b3908c249 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -673,6 +673,34 @@ def test_process_stacktrace_bad_alloc( assert state is False +def test_process_stacktrace_esp32_crash_handler( + setup_core: Path, mock_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP32 crash handler backtrace lines.""" + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp32.crash:078]: PC: 0x400D1234 (fault location)" + state = platformio_api.process_stacktrace(config, line_pc, False) + # PC line is matched by existing STACKTRACE_ESP32_PC_RE + mock_decode_pc.assert_called_with(config, "400D1234") + assert state is False + + mock_decode_pc.reset_mock() + + line_bt0 = "[E][esp32.crash:080]: BT0: 0x400D5678 (backtrace)" + state = platformio_api.process_stacktrace(config, line_bt0, False) + mock_decode_pc.assert_called_once_with(config, "400D5678") + assert state is False + + mock_decode_pc.reset_mock() + + line_bt1 = "[E][esp32.crash:080]: BT1: 0x42005ABC (backtrace)" + state = platformio_api.process_stacktrace(config, line_bt1, False) + mock_decode_pc.assert_called_once_with(config, "42005ABC") + assert state is False + + def test_patch_file_downloader_succeeds_first_try() -> None: """Test patch_file_downloader succeeds on first attempt.""" mock_exception_cls = type("PackageException", (Exception,), {}) From 74153e55acd4c3c53c68c4c307166e2af9c81b69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 15:47:06 -1000 Subject: [PATCH 5/5] [rp2040] Fix crash handler design flaws before release Version the crash data by encoding the version in the magic value (upper 16 bits = 0xDEAD sentinel, lower 16 bits = version). This allows safely changing the scratch register layout in future firmware without misinterpreting old crash data, and costs zero scratch registers (we only have 8). Add USE_RP2040_CRASH_HANDLER define and guard all call sites so the crash handler can be conditionally compiled, matching the ESP32 crash handler pattern. Add crash_handler_has_data() so callers can check for crash data without triggering log output. Log crash data when the API client subscribes to logs so dashboard and HA users see crash reports even when connecting after boot, matching the ESP32 crash handler behavior. --- esphome/components/api/api_connection.h | 6 ++++++ esphome/components/logger/logger_rp2040.cpp | 5 +++++ esphome/components/rp2040/__init__.py | 1 + esphome/components/rp2040/core.cpp | 6 +++++- esphome/components/rp2040/crash_handler.cpp | 23 ++++++++++++++++----- esphome/components/rp2040/crash_handler.h | 8 ++++++- esphome/core/defines.h | 1 + 7 files changed, 43 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 60cc3e91b11..68f698d1902 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -17,6 +17,9 @@ #ifdef USE_ESP32_CRASH_HANDLER #include "esphome/components/esp32/crash_handler.h" #endif +#ifdef USE_RP2040_CRASH_HANDLER +#include "esphome/components/rp2040/crash_handler.h" +#endif #include "esphome/core/entity_base.h" #include "esphome/core/string_ref.h" @@ -240,6 +243,9 @@ class APIConnection final : public APIServerConnectionBase { App.schedule_dump_config(); #ifdef USE_ESP32_CRASH_HANDLER esp32::crash_handler_log(); +#endif +#ifdef USE_RP2040_CRASH_HANDLER + rp2040::crash_handler_log(); #endif } #ifdef USE_API_HOMEASSISTANT_SERVICES diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index f76b823a8f7..b7225c2a258 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -1,6 +1,9 @@ #ifdef USE_RP2040 #include "logger.h" +#include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER #include "esphome/components/rp2040/crash_handler.h" +#endif #include "esphome/core/log.h" namespace esphome::logger { @@ -26,7 +29,9 @@ void Logger::pre_setup() { } global_logger = this; ESP_LOGI(TAG, "Log initialized"); +#ifdef USE_RP2040_CRASH_HANDLER rp2040::crash_handler_log(); +#endif } void HOT Logger::write_msg_(const char *msg, uint16_t len) { diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index b15811241ca..276187b273c 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -212,6 +212,7 @@ async def to_code(config): ) cg.add_define("USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) + cg.add_define("USE_RP2040_CRASH_HANDLER") def add_pio_file(component: str, key: str, data: str): diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index 5e5a96c78b1..7079cbca155 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -1,8 +1,10 @@ #ifdef USE_RP2040 #include "core.h" -#include "crash_handler.h" #include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER +#include "crash_handler.h" +#endif #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -25,7 +27,9 @@ void arch_restart() { } void arch_init() { +#ifdef USE_RP2040_CRASH_HANDLER rp2040::crash_handler_read_and_clear(); +#endif #if USE_RP2040_WATCHDOG_TIMEOUT > 0 watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); #endif diff --git a/esphome/components/rp2040/crash_handler.cpp b/esphome/components/rp2040/crash_handler.cpp index 6ab46da4449..1f579c2d18e 100644 --- a/esphome/components/rp2040/crash_handler.cpp +++ b/esphome/components/rp2040/crash_handler.cpp @@ -1,5 +1,8 @@ #ifdef USE_RP2040 +#include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER + #include "crash_handler.h" #include "esphome/core/log.h" @@ -13,13 +16,19 @@ static constexpr uint32_t EF_LR = 5; static constexpr uint32_t EF_PC = 6; -static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF; +// Version encoded in the magic value: upper 16 bits are sentinel (0xDEAD), +// lower 16 bits are the version number. This avoids using a separate scratch +// register for versioning (we only have 8 total). Future firmware reads the +// sentinel to confirm it's crash data, then the version to know the layout. +static constexpr uint32_t CRASH_MAGIC_SENTINEL = 0xDEAD0000; +static constexpr uint32_t CRASH_DATA_VERSION = 1; +static constexpr uint32_t CRASH_MAGIC_V1 = CRASH_MAGIC_SENTINEL | CRASH_DATA_VERSION; // We only have 8 scratch registers (32 bytes) that survive watchdog reboot. // Use them for the most important data, then scan the stack for code addresses. // // Scratch register layout: -// [0] = magic (CRASH_MAGIC) +// [0] = versioned magic (upper 16 bits = 0xDEAD sentinel, lower 16 bits = version) // [1] = PC (program counter at fault) // [2] = LR (link register from exception frame) // [3] = SP (stack pointer at fault) @@ -57,9 +66,12 @@ static struct { uint8_t backtrace_count; } __attribute__((section(".noinit"))) s_crash_data; +bool crash_handler_has_data() { return s_crash_data.valid; } + void crash_handler_read_and_clear() { s_crash_data.valid = false; - if (watchdog_hw->scratch[0] == CRASH_MAGIC) { + uint32_t magic = watchdog_hw->scratch[0]; + if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) { s_crash_data.valid = true; s_crash_data.pc = watchdog_hw->scratch[1]; s_crash_data.lr = watchdog_hw->scratch[2]; @@ -135,7 +147,7 @@ static void __attribute__((used, noreturn)) hard_fault_handler_c(uint32_t *frame // by a stacking error or corrupted SP, frame may be invalid. Write a minimal // crash marker so we at least know a crash occurred. if (!is_valid_sram_ptr(frame)) { - watchdog_hw->scratch[0] = CRASH_MAGIC; + watchdog_hw->scratch[0] = CRASH_MAGIC_V1; watchdog_hw->scratch[1] = 0; // PC unknown watchdog_hw->scratch[2] = 0; // LR unknown watchdog_hw->scratch[3] = reinterpret_cast(frame); // Record the bad SP for diagnosis @@ -157,7 +169,7 @@ static void __attribute__((used, noreturn)) hard_fault_handler_c(uint32_t *frame uint32_t pre_fault_sp = reinterpret_cast(post_frame); // Write key registers - watchdog_hw->scratch[0] = CRASH_MAGIC; + watchdog_hw->scratch[0] = CRASH_MAGIC_V1; watchdog_hw->scratch[1] = frame[EF_PC]; watchdog_hw->scratch[2] = frame[EF_LR]; watchdog_hw->scratch[3] = pre_fault_sp; @@ -224,4 +236,5 @@ extern "C" void __attribute__((naked, used)) isr_hardfault() { : "i"(hard_fault_handler_c)); } +#endif // USE_RP2040_CRASH_HANDLER #endif // USE_RP2040 diff --git a/esphome/components/rp2040/crash_handler.h b/esphome/components/rp2040/crash_handler.h index f10db47c234..78e8ede08c8 100644 --- a/esphome/components/rp2040/crash_handler.h +++ b/esphome/components/rp2040/crash_handler.h @@ -2,7 +2,9 @@ #ifdef USE_RP2040 -#include +#include "esphome/core/defines.h" + +#ifdef USE_RP2040_CRASH_HANDLER namespace esphome::rp2040 { @@ -12,6 +14,10 @@ void crash_handler_read_and_clear(); /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); +/// Returns true if crash data was found this boot. +bool crash_handler_has_data(); + } // namespace esphome::rp2040 +#endif // USE_RP2040_CRASH_HANDLER #endif // USE_RP2040 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a33f10cb9c0..073170aafbf 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -338,6 +338,7 @@ #ifdef USE_RP2040 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) #define USE_LOOP_PRIORITY +#define USE_RP2040_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_LOGGER_USB_CDC