From b6ff7185e74da275db2bef375732b26d2e125f20 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:04:07 +1300 Subject: [PATCH 01/27] [ci] Dont run codeowners workflows on release or beta PRs (#14703) --- .github/workflows/codeowner-approved-label-update.yml | 3 +++ .github/workflows/codeowner-review-request.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 0bce33ebe2..34ff934b77 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -10,6 +10,9 @@ name: Codeowner Approved Label on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review] + branches-ignore: + - release + - beta permissions: issues: write diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 02bf0e4a29..a89c03ba04 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -13,6 +13,9 @@ on: # Needs to be pull_request_target to get write permissions pull_request_target: types: [opened, reopened, synchronize, ready_for_review] + branches-ignore: + - release + - beta permissions: pull-requests: write From 73f305ff9c9c94c3ca7e7e6a3f2b8e10749a0147 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:28:19 -1000 Subject: [PATCH 02/27] Bump tornado from 6.5.4 to 6.5.5 (#14704) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3da2d52b44..e634bcb104 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.4 +tornado==6.5.5 tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 From a060f175ad04bf0f497a178501ca4df414b002a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:28:46 -1000 Subject: [PATCH 03/27] Bump actions/download-artifact from 8.0.0 to 8.0.1 (#14705) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c9e8c58bc..461e676c4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -945,13 +945,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: memory-analysis-pr path: ./memory-analysis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f68e9c873..0ed41d99c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,7 +171,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download digests - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: digests-* path: /tmp/digests From 3a7a552f0db443beb0cf550988f3b9a017411e16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 10:34:00 -1000 Subject: [PATCH 04/27] [esp32] Add crash handler to capture and report backtrace across reboots When an ESP32 crashes, the backtrace is printed to UART and lost. Users without a serial cable never see this diagnostic information. This adds a crash handler that: - Intercepts esp_panic_handler() via --wrap linker flag - Captures the faulting PC and backtrace into .noinit memory - Supports both Xtensa (ESP32/S2/S3) and RISC-V (C3/C6/H2/C2) - Logs crash data at boot via ESP_LOGE (serial output) - Re-logs when HA subscribes to logs (visible in HA log viewer) - Adds CLI stacktrace decoding for the new log format --- esphome/components/api/api_connection.h | 6 + esphome/components/esp32/__init__.py | 1 + esphome/components/esp32/core.cpp | 4 + esphome/components/esp32/crash_handler.cpp | 177 +++++++++++++++++++++ esphome/components/esp32/crash_handler.h | 20 +++ esphome/components/logger/logger_esp32.cpp | 2 + esphome/platformio_api.py | 7 + 7 files changed, 217 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 3356511684..6af142fecb 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 +#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 + 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/esp32/__init__.py b/esphome/components/esp32/__init__.py index 52e70501dc..630db2271b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1442,6 +1442,7 @@ 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") + cg.add_build_flag("-Wl,--wrap=esp_panic_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 46c000562e..7b13f4b4a3 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -1,5 +1,6 @@ #ifdef USE_ESP32 +#include "crash_handler.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -36,6 +37,9 @@ void arch_restart() { } void arch_init() { + // Read crash data from previous boot before anything else + esp32::crash_handler_read_and_clear(); + // 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 0000000000..a1ecca8062 --- /dev/null +++ b/esphome/components/esp32/crash_handler.cpp @@ -0,0 +1,177 @@ +#ifdef USE_ESP32 + +#include "crash_handler.h" +#include "esphome/core/log.h" + +#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 = 8; + +// 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); +} + +// Raw crash data written by the panic handler wrapper. +// Lives in .noinit so it survives software reset. +// Defined at file scope (outside any namespace) because both the namespace +// functions and the extern "C" panic handler wrapper need to access it. +struct RawCrashData { + uint32_t magic; + uint32_t pc; + uint32_t backtrace[MAX_BACKTRACE]; + uint8_t backtrace_count; +}; +extern RawCrashData s_raw_crash_data; + +namespace esphome::esp32 { + +static const char *const TAG = "esp32.crash"; + +// Validated crash data — populated by crash_handler_read_and_clear() from the +// raw NOINIT data written by the panic handler wrapper. +static struct { + bool valid; + uint32_t pc; + uint32_t backtrace[MAX_BACKTRACE]; + uint8_t backtrace_count; +} s_crash_data; + +void crash_handler_read_and_clear() { + s_crash_data.valid = false; + if (s_raw_crash_data.magic == CRASH_MAGIC) { + s_crash_data.valid = true; + s_crash_data.pc = s_raw_crash_data.pc; + s_crash_data.backtrace_count = s_raw_crash_data.backtrace_count; + if (s_crash_data.backtrace_count > MAX_BACKTRACE) + s_crash_data.backtrace_count = MAX_BACKTRACE; + for (uint8_t i = 0; i < s_crash_data.backtrace_count; i++) { + s_crash_data.backtrace[i] = s_raw_crash_data.backtrace[i]; + } + } + // 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; } + +// 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 ***"); + ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_crash_data.pc); + for (uint8_t i = 0; i < s_crash_data.backtrace_count; i++) { + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (backtrace)", i, s_crash_data.backtrace[i]); + } + // 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_crash_data.pc); + for (uint8_t i = 0; i < s_crash_data.backtrace_count && pos < (int) sizeof(hint) - 12; i++) { + pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_crash_data.backtrace[i]); + } + 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. +// +// The raw crash data struct must be separate from the read-side struct to avoid +// BSS initialization conflicts. It lives in .noinit so it survives software reset. + +RawCrashData __attribute__((section(".noinit"))) s_raw_crash_data; + +extern "C" { +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 + s_raw_crash_data.pc = (uint32_t) info->addr; + s_raw_crash_data.backtrace_count = 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; + 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 + if (is_code_addr(esp_cpu_process_stack_pc(bt_frame.pc))) { + s_raw_crash_data.backtrace[count++] = esp_cpu_process_stack_pc(bt_frame.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; + 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; + } + + // Scan stack for additional code addresses (like RP2040 approach) + 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 magic last — ensures all data is written before we mark it valid + 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); +} + +} // extern "C" + +#endif // USE_ESP32 diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h new file mode 100644 index 0000000000..39a764e804 --- /dev/null +++ b/esphome/components/esp32/crash_handler.h @@ -0,0 +1,20 @@ +#pragma once + +#ifdef USE_ESP32 + +#include + +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 diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index d6ad77ff4f..53768b3796 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,7 @@ void Logger::pre_setup() { esp_log_set_vprintf(esp_idf_log_vprintf_); ESP_LOGI(TAG, "Log initialized"); + esp32::crash_handler_log(); } void HOT Logger::write_msg_(const char *msg, uint16_t len) { diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index 5d4065207f..cb080b2a95 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: From 1c6dd565129f39e23683744b6fc4ecc924210e97 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 10:38:49 -1000 Subject: [PATCH 05/27] Address review feedback - Clear valid flag after logging to prevent re-logging on API reconnects - Cache esp_cpu_process_stack_pc result to avoid redundant call - Remove unused include from header --- esphome/components/esp32/crash_handler.cpp | 7 +++++-- esphome/components/esp32/crash_handler.h | 2 -- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index a1ecca8062..4bebef045f 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -89,6 +89,8 @@ void crash_handler_log() { pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_crash_data.backtrace[i]); } ESP_LOGE(TAG, "%s", hint); + // Clear so we don't re-log on subsequent API reconnects + s_crash_data.valid = false; } } // namespace esphome::esp32 @@ -123,8 +125,9 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { uint8_t count = 0; // First frame PC - if (is_code_addr(esp_cpu_process_stack_pc(bt_frame.pc))) { - s_raw_crash_data.backtrace[count++] = esp_cpu_process_stack_pc(bt_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) { diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h index 39a764e804..c2c42c4ffd 100644 --- a/esphome/components/esp32/crash_handler.h +++ b/esphome/components/esp32/crash_handler.h @@ -2,8 +2,6 @@ #ifdef USE_ESP32 -#include - namespace esphome::esp32 { /// Read crash data from NOINIT memory and clear the magic marker. From 84813ded943b275977ad3495492835397758cbf0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 10:43:52 -1000 Subject: [PATCH 06/27] Add test for ESP32 crash handler stacktrace decoding --- tests/unit_tests/test_platformio_api.py | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index 1686144277..e1b3908c24 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 9f61331187ec58b9720c31ebabda6bc6e5de527d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 10:46:49 -1000 Subject: [PATCH 07/27] Address Copilot review: static linkage + keep valid flag - Make s_raw_crash_data static with inline .noinit definition (no extern needed) - Remove valid=false clearing from crash_handler_log() so both serial (boot) and API (subscribe) paths can emit the crash data --- esphome/components/esp32/crash_handler.cpp | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 4bebef045f..cdd5139235 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -26,16 +26,14 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t addr) { } // Raw crash data written by the panic handler wrapper. -// Lives in .noinit so it survives software reset. -// Defined at file scope (outside any namespace) because both the namespace -// functions and the extern "C" panic handler wrapper need to access it. -struct RawCrashData { +// 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. +static struct { uint32_t magic; uint32_t pc; uint32_t backtrace[MAX_BACKTRACE]; uint8_t backtrace_count; -}; -extern RawCrashData s_raw_crash_data; +} __attribute__((section(".noinit"))) s_raw_crash_data; namespace esphome::esp32 { @@ -89,8 +87,6 @@ void crash_handler_log() { pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_crash_data.backtrace[i]); } ESP_LOGE(TAG, "%s", hint); - // Clear so we don't re-log on subsequent API reconnects - s_crash_data.valid = false; } } // namespace esphome::esp32 @@ -99,11 +95,6 @@ void crash_handler_log() { // Intercepts esp_panic_handler() via --wrap linker flag to capture crash data // into NOINIT memory before the normal panic handler runs. // -// The raw crash data struct must be separate from the read-side struct to avoid -// BSS initialization conflicts. It lives in .noinit so it survives software reset. - -RawCrashData __attribute__((section(".noinit"))) s_raw_crash_data; - extern "C" { extern void __real_esp_panic_handler(panic_info_t *info); From a80cc50b8ff3131dd44f99a723ec6d771d713cb9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 10:55:00 -1000 Subject: [PATCH 08/27] fix not survive --- esphome/components/esp32/crash_handler.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index cdd5139235..563f0cbdf4 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -28,12 +28,13 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t addr) { // 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. -static struct { +struct RawCrashData { uint32_t magic; uint32_t pc; uint32_t backtrace[MAX_BACKTRACE]; uint8_t backtrace_count; -} __attribute__((section(".noinit"))) s_raw_crash_data; +}; +static RawCrashData __attribute__((section(".noinit"))) s_raw_crash_data; namespace esphome::esp32 { From 5adae5281976eb9cd914fe80f7f680b6b2d089e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 11:05:52 -1000 Subject: [PATCH 09/27] Add stacktrace decoding to API log path The esphome logs command via API wasn't running process_stacktrace on received log lines, so crash handler backtrace addresses were displayed but not decoded with addr2line. --- esphome/components/api/client.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 200d0938bd..a8df7f0713 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,12 @@ 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) + if platform_process_stacktrace: + backtrace_state = platform_process_stacktrace(config, text, backtrace_state) + else: + backtrace_state = process_stacktrace( + config, text, backtrace_state=backtrace_state + ) stop = await async_run(cli, on_log, name=name) try: From 7acf3f77c45000a5d18638368c7237700d3e81e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 11:06:15 -1000 Subject: [PATCH 10/27] Increase max backtrace depth from 8 to 16 8 frames was cutting off useful call stack information. 16 frames costs an additional 32 bytes of .noinit RAM and covers typical ESPHome call chains which can be 10-12 frames deep. --- esphome/components/esp32/crash_handler.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 563f0cbdf4..043f64acf6 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -17,7 +17,7 @@ #endif static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF; -static constexpr size_t MAX_BACKTRACE = 8; +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). From f37610bfe155df4da2b718d47aa89ec20970d550 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 11:19:40 -1000 Subject: [PATCH 11/27] Eliminate duplicate BSS struct, read directly from .noinit data Saves 76 bytes of RAM by removing the validated BSS copy and reading directly from the .noinit struct after magic validation. A single bool tracks whether valid crash data was found this boot. Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 41 ++++++++-------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 043f64acf6..efedb42889 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -36,36 +36,25 @@ struct RawCrashData { }; static RawCrashData __attribute__((section(".noinit"))) s_raw_crash_data; +// Whether crash data was found and validated this boot. +static bool s_crash_data_valid = false; + namespace esphome::esp32 { static const char *const TAG = "esp32.crash"; -// Validated crash data — populated by crash_handler_read_and_clear() from the -// raw NOINIT data written by the panic handler wrapper. -static struct { - bool valid; - uint32_t pc; - uint32_t backtrace[MAX_BACKTRACE]; - uint8_t backtrace_count; -} s_crash_data; - void crash_handler_read_and_clear() { - s_crash_data.valid = false; if (s_raw_crash_data.magic == CRASH_MAGIC) { - s_crash_data.valid = true; - s_crash_data.pc = s_raw_crash_data.pc; - s_crash_data.backtrace_count = s_raw_crash_data.backtrace_count; - if (s_crash_data.backtrace_count > MAX_BACKTRACE) - s_crash_data.backtrace_count = MAX_BACKTRACE; - for (uint8_t i = 0; i < s_crash_data.backtrace_count; i++) { - s_crash_data.backtrace[i] = s_raw_crash_data.backtrace[i]; - } + s_crash_data_valid = true; + // Clamp backtrace count 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; } // 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; } +bool crash_handler_has_data() { return s_crash_data_valid; } // 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 @@ -73,19 +62,19 @@ bool crash_handler_has_data() { return s_crash_data.valid; } // 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) + if (!s_crash_data_valid) return; ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); - ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_crash_data.pc); - for (uint8_t i = 0; i < s_crash_data.backtrace_count; i++) { - ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (backtrace)", i, s_crash_data.backtrace[i]); + ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc); + for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count; i++) { + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (backtrace)", i, s_raw_crash_data.backtrace[i]); } // 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_crash_data.pc); - for (uint8_t i = 0; i < s_crash_data.backtrace_count && pos < (int) sizeof(hint) - 12; i++) { - pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_crash_data.backtrace[i]); + 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++) { + pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_raw_crash_data.backtrace[i]); } ESP_LOGE(TAG, "%s", hint); } From f2dee1433c21ffa3ded4521d28b3418e28560085 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 11:21:05 -1000 Subject: [PATCH 12/27] Place backtrace_count before array for forward compatibility Moving backtrace_count before the variable-length backtrace array ensures magic, pc, and count are at fixed offsets regardless of MAX_BACKTRACE value. This makes the .noinit data readable across firmware versions that may change the max backtrace depth. Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index efedb42889..53ce16e11c 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -28,11 +28,13 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t addr) { // 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. +// Field order matters: magic, pc, and backtrace_count are at fixed offsets +// so the struct remains readable even if MAX_BACKTRACE changes between versions. struct RawCrashData { uint32_t magic; uint32_t pc; - uint32_t backtrace[MAX_BACKTRACE]; uint8_t backtrace_count; + uint32_t backtrace[MAX_BACKTRACE]; }; static RawCrashData __attribute__((section(".noinit"))) s_raw_crash_data; From 5e472e8171677632708df6c3135d649bb106adfe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 11:22:26 -1000 Subject: [PATCH 13/27] Add version field to crash data struct for future extensibility Allows future firmware to detect and skip incompatible crash data layouts. Placed alongside backtrace_count to avoid adding padding. Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 53ce16e11c..cabc433520 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -28,12 +28,15 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t addr) { // 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. -// Field order matters: magic, pc, and backtrace_count are at fixed offsets +// Field order matters: magic, version, pc, and backtrace_count are at fixed offsets // so the struct remains readable even if MAX_BACKTRACE changes between versions. +static constexpr uint8_t CRASH_DATA_VERSION = 1; struct RawCrashData { uint32_t magic; uint32_t pc; + uint8_t version; uint8_t backtrace_count; + // 2 bytes padding here, then backtrace array uint32_t backtrace[MAX_BACKTRACE]; }; static RawCrashData __attribute__((section(".noinit"))) s_raw_crash_data; @@ -46,7 +49,7 @@ namespace esphome::esp32 { static const char *const TAG = "esp32.crash"; void crash_handler_read_and_clear() { - if (s_raw_crash_data.magic == CRASH_MAGIC) { + if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { s_crash_data_valid = true; // Clamp backtrace count to prevent out-of-bounds reads from corrupt .noinit data if (s_raw_crash_data.backtrace_count > MAX_BACKTRACE) @@ -151,7 +154,8 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { } #endif - // Write magic last — ensures all data is written before we mark it valid + // 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.) From b08d19bd7e92f604942fdf78b674606de7758c2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 11:23:00 -1000 Subject: [PATCH 14/27] Move version to first field as uint32_t Version first ensures future firmware can always identify the struct layout without depending on any other field positions. Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index cabc433520..af4dc538ca 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -28,15 +28,14 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t addr) { // 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. -// Field order matters: magic, version, pc, and backtrace_count are at fixed offsets -// so the struct remains readable even if MAX_BACKTRACE changes between versions. -static constexpr uint8_t CRASH_DATA_VERSION = 1; +// 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. +static constexpr uint32_t CRASH_DATA_VERSION = 1; struct RawCrashData { + uint32_t version; uint32_t magic; uint32_t pc; - uint8_t version; uint8_t backtrace_count; - // 2 bytes padding here, then backtrace array uint32_t backtrace[MAX_BACKTRACE]; }; static RawCrashData __attribute__((section(".noinit"))) s_raw_crash_data; From 2e77f5da253ade1f09117203353ec4ecd4e76a9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 11:23:28 -1000 Subject: [PATCH 15/27] Add comment explaining why version is uint32_t Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index af4dc538ca..70129d87ff 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -30,6 +30,8 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t addr) { // 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; From f3923ff170e21c805cba3e852fe581dbba2bb685 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 11:37:57 -1000 Subject: [PATCH 16/27] Filter RISC-V backtrace by validating return addresses at log time Stack scanning captures any value that looks like a code address, which includes false positives. At log time (flash cache is up), validate each address by checking if the preceding instruction is a JAL/JALR with rd=ra. This filters spurious entries like FreeRTOS internals that happen to be on the stack but aren't part of the actual call chain. Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 34 ++++++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 70129d87ff..c54b1688c1 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -25,6 +25,20 @@ 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; + uint32_t inst = *(uint32_t *) (addr - 4); + uint32_t opcode = inst & 0x7f; + // JAL (opcode 0x6f) or JALR (opcode 0x67) with rd=x1 (ra) + return (opcode == 0x6f || opcode == 0x67) && (inst & 0xf80) == 0x80; +} +#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. @@ -73,14 +87,27 @@ void crash_handler_log() { ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); 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++) { - ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (backtrace)", i, s_raw_crash_data.backtrace[i]); + uint32_t addr = s_raw_crash_data.backtrace[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + // Filter stack-scanned addresses: skip values that aren't preceded by a + // JAL/JALR call instruction. Safe to check here since flash cache is up. + if (!is_return_addr(addr)) + continue; +#endif + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (backtrace)", bt_num++, addr); } // 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++) { - pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_raw_crash_data.backtrace[i]); + uint32_t addr = s_raw_crash_data.backtrace[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + if (!is_return_addr(addr)) + continue; +#endif + pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, addr); } ESP_LOGE(TAG, "%s", hint); } @@ -143,7 +170,8 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { s_raw_crash_data.backtrace[count++] = rv_frame->ra; } - // Scan stack for additional code addresses (like RP2040 approach) + // 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]; From e42a8c5a0cdbc24e880e6acb1a03a2c0f8304f9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 11:38:55 -1000 Subject: [PATCH 17/27] Add comments explaining RISC-V instruction encoding bit masks Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index c54b1688c1..0ee984d49f 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -32,10 +32,14 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t addr) { 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. + // Read the 4-byte instruction immediately before this address. uint32_t inst = *(uint32_t *) (addr - 4); - uint32_t opcode = inst & 0x7f; - // JAL (opcode 0x6f) or JALR (opcode 0x67) with rd=x1 (ra) - return (opcode == 0x6f || opcode == 0x67) && (inst & 0xf80) == 0x80; + // 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) + return (opcode == 0x6f || opcode == 0x67) && rd == 0x80; } #endif From b38bcfe4667b241f9d844b5fdc0c577ed6d42042 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 11:39:52 -1000 Subject: [PATCH 18/27] Add hint for RISC-V users to check serial console for full trace Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 0ee984d49f..06a8729323 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -114,6 +114,9 @@ void crash_handler_log() { pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, addr); } ESP_LOGE(TAG, "%s", hint); +#if CONFIG_IDF_TARGET_ARCH_RISCV + ESP_LOGE(TAG, "RISC-V backtrace is best-effort. Check serial console for full register dump."); +#endif } } // namespace esphome::esp32 From 8e37d8c57da1f650651969316255a2f6dd832da6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 11:41:07 -1000 Subject: [PATCH 19/27] Remove misleading serial console hint for RISC-V MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IDF doesn't decode RISC-V backtraces on the console either — it just dumps raw stack memory. Our crash handler provides a better trace. Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 06a8729323..0ee984d49f 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -114,9 +114,6 @@ void crash_handler_log() { pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, addr); } ESP_LOGE(TAG, "%s", hint); -#if CONFIG_IDF_TARGET_ARCH_RISCV - ESP_LOGE(TAG, "RISC-V backtrace is best-effort. Check serial console for full register dump."); -#endif } } // namespace esphome::esp32 From b1317939dace9f7c2077e85d778850b1b1aba76f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 11:43:45 -1000 Subject: [PATCH 20/27] Fix clang-tidy errors: NOLINT for mutable globals and linker symbols - s_raw_crash_data and s_crash_data_valid must be mutable globals - __real_/__wrap_ names are mandated by the --wrap linker mechanism Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 0ee984d49f..75f0c4fd49 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -58,10 +58,11 @@ struct RawCrashData { uint8_t backtrace_count; uint32_t backtrace[MAX_BACKTRACE]; }; -static RawCrashData __attribute__((section(".noinit"))) s_raw_crash_data; +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; +static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) namespace esphome::esp32 { @@ -123,6 +124,8 @@ void crash_handler_log() { // 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) { @@ -195,6 +198,7 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { __real_esp_panic_handler(info); } +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) } // extern "C" #endif // USE_ESP32 From 9bcf6adaedb06b0dcb7ae2647a9ee37256199c50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 11:57:54 -1000 Subject: [PATCH 21/27] Improve RISC-V return address validation - Add compressed c.jalr (2-byte) instruction check alongside 4-byte JAL/JALR, since ESP32 RISC-V targets have the C extension enabled. - Track register-sourced entries (MEPC/RA) separately from stack-scanned ones, and skip return-address validation for register entries since they are known-good values from the exception frame. Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 30 +++++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 75f0c4fd49..94c6bc16c0 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -33,13 +33,22 @@ 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. - // Read the 4-byte instruction immediately before this address. + // Check for 4-byte JAL/JALR call instruction before this address. uint32_t inst = *(uint32_t *) (addr - 4); // 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) - return (opcode == 0x6f || opcode == 0x67) && rd == 0x80; + 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 @@ -56,6 +65,7 @@ struct RawCrashData { uint32_t magic; uint32_t pc; uint8_t backtrace_count; + uint8_t reg_frame_count; // Number of entries from registers (not stack-scanned) uint32_t backtrace[MAX_BACKTRACE]; }; static RawCrashData __attribute__((section(".noinit"))) @@ -71,9 +81,11 @@ 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 backtrace count to prevent out-of-bounds reads from corrupt .noinit data + // 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; } // Clear magic regardless so we don't re-report on next normal reboot s_raw_crash_data.magic = 0; @@ -96,9 +108,8 @@ void crash_handler_log() { 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 - // Filter stack-scanned addresses: skip values that aren't preceded by a - // JAL/JALR call instruction. Safe to check here since flash cache is up. - if (!is_return_addr(addr)) + // 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 ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (backtrace)", bt_num++, addr); @@ -109,7 +120,7 @@ void crash_handler_log() { 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 (!is_return_addr(addr)) + 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); @@ -132,6 +143,7 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // Save the faulting PC s_raw_crash_data.pc = (uint32_t) info->addr; s_raw_crash_data.backtrace_count = 0; + s_raw_crash_data.reg_frame_count = 0; #if CONFIG_IDF_TARGET_ARCH_XTENSA // Xtensa: walk the backtrace using the public API @@ -177,6 +189,10 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { 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; From 8eea2ce7ca918aa76b81a4545af581f9055460e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 12:00:36 -1000 Subject: [PATCH 22/27] Address Copilot review: per-line stacktrace + alignment-safe read - Split text into lines before calling process_stacktrace in client.py, since process_stacktrace uses re.match and expects individual lines. - Use memcpy instead of direct pointer cast for reading the instruction before a return address, since RISC-V C extension means code addresses are only 2-byte aligned and addr-4 may not be 4-byte aligned. Co-Authored-By: J. Nick Koston --- esphome/components/api/client.py | 15 +++++++++------ esphome/components/esp32/crash_handler.cpp | 6 +++++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index a8df7f0713..0e71ad8fcb 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -79,12 +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) - if platform_process_stacktrace: - backtrace_state = platform_process_stacktrace(config, text, backtrace_state) - else: - backtrace_state = process_stacktrace( - config, text, backtrace_state=backtrace_state - ) + 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/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 94c6bc16c0..fa66209cfe 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -4,6 +4,7 @@ #include "esphome/core/log.h" #include +#include #include #include #include @@ -34,7 +35,10 @@ static inline bool is_return_addr(uint32_t addr) { 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. - uint32_t inst = *(uint32_t *) (addr - 4); + // 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) From 19c3187bb5c7b056bcb34dbe54ecec613c42d993 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 12:06:18 -1000 Subject: [PATCH 23/27] Distinguish trusted vs stack-scanned frames in RISC-V output On RISC-V, register-sourced entries (MEPC/RA) are labeled "backtrace" while stack-scanned entries are labeled "stack scan" to help users identify which frames are most trustworthy. Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index fa66209cfe..82a3839501 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -116,7 +116,12 @@ void crash_handler_log() { if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) continue; #endif - ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (backtrace)", bt_num++, addr); +#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]; From 1b438b25141ced4f4c66475133afad3e88666c9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 12:28:35 -1000 Subject: [PATCH 24/27] Log exception cause reason in crash report Store the exception cause register (exccause/mcause) and exception type (panic_exception_t) in the .noinit struct. At log time, look up the cause code in architecture-specific tables mirroring ESP-IDF's internal panic_arch_fill_info() arrays. Output now shows e.g.: Reason: Fault - Store access fault (RISC-V) Reason: Fault - StoreProhibited (Xtensa) Reason: Interrupt wdt (watchdog, type-only) Bumps CRASH_DATA_VERSION to 2. Struct gains +4 bytes (cause field) with no padding increase (exception and pseudo_excause fit in existing padding alongside backtrace_count and reg_frame_count). All fields are clamped on read to prevent corrupt .noinit data from causing out-of-bounds array access. Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 126 ++++++++++++++++++++- 1 file changed, 124 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 82a3839501..ad21786d0f 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -63,14 +63,17 @@ static inline bool is_return_addr(uint32_t addr) { // 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; +static constexpr uint32_t CRASH_DATA_VERSION = 2; 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) @@ -90,6 +93,10 @@ void crash_handler_read_and_clear() { 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; @@ -97,6 +104,111 @@ void crash_handler_read_and_clear() { 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 @@ -107,6 +219,12 @@ void crash_handler_log() { 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++) { @@ -149,15 +267,18 @@ extern "C" { 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 + // 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, @@ -188,6 +309,7 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // 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) From 7e484d16eeffd2791671a407df56eeea3de3f756 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 12:29:19 -1000 Subject: [PATCH 25/27] Keep CRASH_DATA_VERSION at 1 since version 1 was never shipped Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index ad21786d0f..b8fb99b5e4 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -63,7 +63,7 @@ static inline bool is_return_addr(uint32_t addr) { // 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 = 2; +static constexpr uint32_t CRASH_DATA_VERSION = 1; struct RawCrashData { uint32_t version; uint32_t magic; From 147eae4b3642ff56111862ea9f8ff191d5b6447d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 12:37:49 -1000 Subject: [PATCH 26/27] Fix clang-tidy: UPPER_SNAKE_CASE for static local constants Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 26 +++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index b8fb99b5e4..5b8e1a6c97 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -112,7 +112,7 @@ static const char *get_exception_reason() { 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[] = { + static const char *const PSEUDO_REASON[] = { "Unknown reason", // 0 "Unhandled debug exception", // 1 "Double exception", // 2 @@ -123,12 +123,12 @@ static const char *get_exception_reason() { "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]; + 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[] = { + static const char *const REASON[] = { "IllegalInstruction", "Syscall", "InstructionFetchError", @@ -161,15 +161,15 @@ static const char *get_exception_reason() { "StoreProhibited", }; uint32_t cause = s_raw_crash_data.cause; - if (cause < sizeof(reason) / sizeof(reason[0]) && reason[cause] != nullptr) - return reason[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[] = { + static const char *const REASON[] = { "Instruction address misaligned", "Instruction access fault", "Illegal instruction", @@ -188,15 +188,15 @@ static const char *get_exception_reason() { "Store page fault", }; uint32_t cause = s_raw_crash_data.cause; - if (cause < sizeof(reason) / sizeof(reason[0]) && reason[cause] != nullptr) - return reason[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[] = { + static const char *const TYPES[] = { "Debug exception", // PANIC_EXCEPTION_DEBUG "Interrupt wdt", // PANIC_EXCEPTION_IWDT "Task wdt", // PANIC_EXCEPTION_TWDT @@ -204,8 +204,8 @@ static const char *get_exception_type() { "Fault", // PANIC_EXCEPTION_FAULT }; uint8_t exc = s_raw_crash_data.exception; - if (exc < sizeof(types) / sizeof(types[0])) - return types[exc]; + if (exc < sizeof(TYPES) / sizeof(TYPES[0])) + return TYPES[exc]; return "Unknown"; } From 35801d795a91623e122b8a445888ab7f60851206 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 12:39:55 -1000 Subject: [PATCH 27/27] Fix missed rename: reason[0] -> REASON[0] in sizeof expressions Co-Authored-By: J. Nick Koston --- esphome/components/esp32/crash_handler.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 5b8e1a6c97..1aa4f65142 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -161,7 +161,7 @@ static const char *get_exception_reason() { "StoreProhibited", }; uint32_t cause = s_raw_crash_data.cause; - if (cause < sizeof(REASON) / sizeof(reason[0]) && REASON[cause] != nullptr) + 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 @@ -188,7 +188,7 @@ static const char *get_exception_reason() { "Store page fault", }; uint32_t cause = s_raw_crash_data.cause; - if (cause < sizeof(REASON) / sizeof(reason[0]) && REASON[cause] != nullptr) + if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) return REASON[cause]; #endif return "Unknown";