mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 22:06:12 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e0e1be71 | ||
|
|
7a7e4695e2 | ||
|
|
106fa763ab | ||
|
|
30a568b804 | ||
|
|
090f8a0caf | ||
|
|
9aeac52ba0 | ||
|
|
5ceafcee97 | ||
|
|
2be625293b | ||
|
|
9ee5aaf3ec | ||
|
|
730e05b0c1 | ||
|
|
9f4dd988dc | ||
|
|
609df3f438 |
@@ -29,6 +29,7 @@ from esphome.components.libretiny.const import (
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .boards import BK72XX_BOARD_PINS, BK72XX_BOARDS
|
||||
|
||||
@@ -55,6 +55,9 @@ from .const import (
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
# Fault handlers the crash handler wraps (BK7238, BK7231N)
|
||||
BK_TRAP_HANDLERS = ("bk_trap_udef", "bk_trap_pabt", "bk_trap_dabt")
|
||||
|
||||
CODEOWNERS = ["@kuba2k2"]
|
||||
AUTO_LOAD = ["preferences"]
|
||||
DOMAIN = "libretiny"
|
||||
@@ -530,6 +533,13 @@ async def component_to_code(config):
|
||||
# RAM-executable output section and prints a post-link placement summary.
|
||||
if FAMILY_COMPONENT[config[CONF_FAMILY]] != COMPONENT_BK72XX:
|
||||
cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"])
|
||||
if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238):
|
||||
# Crash handler (crash_handler.cpp, patch_noinit.py.script)
|
||||
cg.add_define("USE_LIBRETINY_CRASH_HANDLER")
|
||||
cg.add_define("USE_CRASH_HANDLER")
|
||||
for trap in BK_TRAP_HANDLERS:
|
||||
cg.add_build_flag(f"-Wl,--wrap={trap}")
|
||||
cg.add_platformio_option("extra_scripts", ["pre:patch_noinit.py"])
|
||||
# Match the 1-byte enums of the LN882H SDK's prebuilt WiFi library.
|
||||
if FAMILY_COMPONENT[config[CONF_FAMILY]] == COMPONENT_LN882X:
|
||||
cg.add_build_unflag("-fno-short-enums")
|
||||
@@ -637,4 +647,8 @@ def copy_files() -> None:
|
||||
script_dir / "scons_dont_inherit.py.script",
|
||||
CORE.relative_build_path("scons_dont_inherit.py"),
|
||||
)
|
||||
copy_file_if_changed(
|
||||
script_dir / "patch_noinit.py.script",
|
||||
CORE.relative_build_path("patch_noinit.py"),
|
||||
)
|
||||
copy_ccache_script()
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
#ifdef USE_BK72XX
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_LIBRETINY_CRASH_HANDLER
|
||||
|
||||
#include "crash_handler.h"
|
||||
#include "core.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
#include <cinttypes>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
extern "C" {
|
||||
#include <arm_mcu_pub.h> // MCU_REG_BACKUP_SP_*
|
||||
#include <intc.h> // struct arm_registers
|
||||
}
|
||||
|
||||
// On a fault the SDK (boot_handlers.S, PUSH_SVC_REG) builds a struct arm_registers frame and branches
|
||||
// to bk_trap_udef/pabt/dabt, which record the crash type, print a dump sdk_silent hides and spin for
|
||||
// the watchdog. The wrappers (-Wl,--wrap) first save the fault location and a stack scan for the
|
||||
// next boot. pc is the exception LR; PUSH_SVC_REG stores pc and spsr at these word offsets.
|
||||
static_assert(sizeof(arm_registers) == 18 * sizeof(uint32_t), "SDK exception frame changed size");
|
||||
static_assert(offsetof(arm_registers, pc) == 15 * 4 && offsetof(arm_registers, spsr) == 16 * 4,
|
||||
"SDK exception frame layout changed");
|
||||
static_assert(LT_HW_WATCHDOG, "the crash reboot relies on the hardware watchdog");
|
||||
|
||||
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
extern "C" {
|
||||
void __real_bk_trap_udef(arm_registers *regs);
|
||||
void __real_bk_trap_pabt(arm_registers *regs);
|
||||
void __real_bk_trap_dabt(arm_registers *regs);
|
||||
// Bounds of the app's flash image and its ITCM code (linker symbols).
|
||||
extern char _vector_start[];
|
||||
extern char _data_flash_begin[];
|
||||
extern char _itcmcode_ram_begin[];
|
||||
extern char _itcmcode_ram_end[];
|
||||
}
|
||||
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
|
||||
namespace esphome::libretiny {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "libretiny.crash");
|
||||
|
||||
// 0xDEAD sentinel, then the record layout version.
|
||||
static constexpr uint32_t CRASH_MAGIC_V1 = 0xDEAD0001;
|
||||
|
||||
static constexpr uint32_t CRASH_REASON_UNDEFINED = 1;
|
||||
static constexpr uint32_t CRASH_REASON_PREFETCH_ABORT = 2;
|
||||
static constexpr uint32_t CRASH_REASON_DATA_ABORT = 3;
|
||||
PROGMEM_STRING_TABLE(CrashReasonStrings, "Unknown", "Undefined instruction", "Prefetch abort", "Data abort");
|
||||
|
||||
// With Wi-Fi up the boot-time watchdog no longer fires: any interrupts-off spin hangs until power is
|
||||
// cycled (BK7238 and BK7231N; cause not in the open SDK). Re-armed here, long enough for the SDK's
|
||||
// register dump when sdk_silent is off.
|
||||
static constexpr uint32_t CRASH_REBOOT_WDT_MS = 500;
|
||||
|
||||
static constexpr size_t MAX_BACKTRACE = 8;
|
||||
// Words scanned above sp for return addresses.
|
||||
static constexpr size_t STACK_SCAN_WORDS = 64;
|
||||
|
||||
// SRAM: 192 KB on the BK7231N, 256 KB on the BK7238.
|
||||
static constexpr uint32_t SRAM_START = 0x00400000;
|
||||
#ifdef USE_LIBRETINY_VARIANT_BK7231N
|
||||
static constexpr uint32_t SRAM_END = 0x00430000;
|
||||
#else
|
||||
static constexpr uint32_t SRAM_END = 0x00440000;
|
||||
#endif
|
||||
|
||||
static constexpr uint32_t CPSR_MODE_MASK = 0x1F;
|
||||
static constexpr uint32_t CPSR_MODE_USR = 0x10;
|
||||
static constexpr uint32_t CPSR_MODE_FIQ = 0x11;
|
||||
static constexpr uint32_t CPSR_MODE_IRQ = 0x12;
|
||||
static constexpr uint32_t CPSR_MODE_SVC = 0x13;
|
||||
static constexpr uint32_t CPSR_MODE_ABT = 0x17;
|
||||
static constexpr uint32_t CPSR_MODE_UND = 0x1B;
|
||||
static constexpr uint32_t CPSR_MODE_SYS = 0x1F;
|
||||
static constexpr uint32_t CPSR_THUMB = 1u << 5;
|
||||
|
||||
struct CrashRecord {
|
||||
uint32_t magic;
|
||||
uint32_t reason;
|
||||
uint32_t pc;
|
||||
uint32_t lr;
|
||||
uint32_t sp;
|
||||
uint32_t backtrace[MAX_BACKTRACE];
|
||||
};
|
||||
|
||||
// Kept in ITCM, see patch_noinit.py.script.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
__attribute__((section(".noinit"))) static CrashRecord s_record;
|
||||
|
||||
static bool s_record_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static bool s_record_valid = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
bool crash_handler_has_data() {
|
||||
if (!s_record_read) {
|
||||
s_record_read = true;
|
||||
s_record_valid = s_record.magic == CRASH_MAGIC_V1;
|
||||
// Clear the magic so the record is reported once.
|
||||
s_record.magic = 0;
|
||||
}
|
||||
return s_record_valid;
|
||||
}
|
||||
|
||||
// One ESP_LOGE per line: the CLI decodes per line and partial output still shows.
|
||||
void crash_handler_log() {
|
||||
if (!crash_handler_has_data())
|
||||
return;
|
||||
|
||||
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
|
||||
ESP_LOGE(TAG, " Reason: %s", LOG_STR_ARG(CrashReasonStrings::get_log_str(s_record.reason, 0)));
|
||||
ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_record.pc);
|
||||
ESP_LOGE(TAG, " LR: 0x%08" PRIX32 " (return address)", s_record.lr);
|
||||
ESP_LOGE(TAG, " SP: 0x%08" PRIX32, s_record.sp);
|
||||
uint8_t count = 0;
|
||||
for (; count < MAX_BACKTRACE && s_record.backtrace[count] != 0; count++) {
|
||||
ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (stack backtrace)", count, s_record.backtrace[count]);
|
||||
}
|
||||
// addr2line hint with every address
|
||||
char hint[160];
|
||||
int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e raw_firmware.elf 0x%08" PRIX32 " 0x%08" PRIX32,
|
||||
s_record.pc, s_record.lr);
|
||||
for (uint8_t i = 0; i < count && pos < (int) sizeof(hint) - 12; i++) {
|
||||
pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_record.backtrace[i]);
|
||||
}
|
||||
ESP_LOGE(TAG, "%s", hint);
|
||||
}
|
||||
|
||||
// --- Crash time: exception mode, interrupts masked, a few dozen bytes of stack: no printing, no
|
||||
// large locals.
|
||||
|
||||
static inline bool is_code_addr(uint32_t val) {
|
||||
uint32_t addr = val & ~1u; // clear the Thumb bit of a return address
|
||||
return (addr >= (uint32_t) _vector_start && addr < (uint32_t) _data_flash_begin) ||
|
||||
(addr >= (uint32_t) _itcmcode_ram_begin && addr < (uint32_t) _itcmcode_ram_end);
|
||||
}
|
||||
|
||||
// PUSH_ALL_ARM_REG (boot_handlers.S) saves every mode's registers at the arm_mcu_pub.h addresses
|
||||
// before bk_trap_*; lr (r14) follows sp (r13).
|
||||
struct SavedBank {
|
||||
uint32_t mode;
|
||||
uint32_t sp_addr;
|
||||
};
|
||||
static constexpr SavedBank SAVED_BANKS[] = {
|
||||
{CPSR_MODE_SYS, MCU_REG_BACKUP_SP_SYS}, {CPSR_MODE_IRQ, MCU_REG_BACKUP_SP_IRQ},
|
||||
{CPSR_MODE_FIQ, MCU_REG_BACKUP_SP_FIQ}, {CPSR_MODE_ABT, MCU_REG_BACKUP_SP_ABT},
|
||||
{CPSR_MODE_UND, MCU_REG_BACKUP_SP_UND}, {CPSR_MODE_SVC, MCU_REG_BACKUP_SP_SVC},
|
||||
};
|
||||
|
||||
// sp and lr stay 0 for an unknown mode (corrupt spsr).
|
||||
static void read_saved_sp_lr(uint32_t mode, uint32_t *sp, uint32_t *lr) {
|
||||
for (const auto &bank : SAVED_BANKS) {
|
||||
if (bank.mode != mode)
|
||||
continue;
|
||||
const auto *regs = (const volatile uint32_t *) bank.sp_addr; // NOLINT(performance-no-int-to-ptr)
|
||||
*sp = regs[0];
|
||||
*lr = regs[1];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
static void record_crash(arm_registers *regs, uint32_t reason, uint32_t pc_offset) {
|
||||
uint32_t mode = regs->spsr & CPSR_MODE_MASK;
|
||||
if (mode == CPSR_MODE_USR)
|
||||
mode = CPSR_MODE_SYS; // same register bank
|
||||
uint32_t sp = 0;
|
||||
uint32_t lr = 0;
|
||||
read_saved_sp_lr(mode, &sp, &lr);
|
||||
|
||||
s_record.reason = reason;
|
||||
s_record.pc = regs->pc - pc_offset;
|
||||
s_record.lr = lr;
|
||||
s_record.sp = sp;
|
||||
|
||||
// Return addresses above sp give the backtrace.
|
||||
uint32_t count = 0;
|
||||
if (sp >= SRAM_START && sp < SRAM_END && (sp & 3) == 0) {
|
||||
auto *scan = (const uint32_t *) sp; // NOLINT(performance-no-int-to-ptr)
|
||||
auto *end = (const uint32_t *) SRAM_END; // NOLINT(performance-no-int-to-ptr)
|
||||
for (size_t i = 0; i < STACK_SCAN_WORDS && scan < end && count < MAX_BACKTRACE; i++, scan++) {
|
||||
uint32_t val = *scan;
|
||||
if (is_code_addr(val) && val != lr && (val & ~1u) != s_record.pc)
|
||||
s_record.backtrace[count++] = val;
|
||||
}
|
||||
}
|
||||
if (count < MAX_BACKTRACE)
|
||||
s_record.backtrace[count] = 0; // terminator, the reader stops at the first 0
|
||||
s_record.magic = CRASH_MAGIC_V1;
|
||||
|
||||
lt_wdt_enable(CRASH_REBOOT_WDT_MS);
|
||||
}
|
||||
|
||||
// The exception LR is past the fault: one instruction (undefined), 4 bytes (prefetch), 8 (data abort).
|
||||
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
extern "C" void __wrap_bk_trap_udef(arm_registers *regs) {
|
||||
record_crash(regs, CRASH_REASON_UNDEFINED, (regs->spsr & CPSR_THUMB) ? 2 : 4);
|
||||
__real_bk_trap_udef(regs);
|
||||
}
|
||||
|
||||
extern "C" void __wrap_bk_trap_pabt(arm_registers *regs) {
|
||||
record_crash(regs, CRASH_REASON_PREFETCH_ABORT, 4);
|
||||
__real_bk_trap_pabt(regs);
|
||||
}
|
||||
|
||||
extern "C" void __wrap_bk_trap_dabt(arm_registers *regs) {
|
||||
record_crash(regs, CRASH_REASON_DATA_ABORT, 8);
|
||||
__real_bk_trap_dabt(regs);
|
||||
}
|
||||
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
|
||||
} // namespace esphome::libretiny
|
||||
|
||||
#endif // USE_LIBRETINY_CRASH_HANDLER
|
||||
#endif // USE_BK72XX
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_BK72XX
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_LIBRETINY_CRASH_HANDLER
|
||||
|
||||
namespace esphome::libretiny {
|
||||
|
||||
/// Log crash data if a crash was detected on previous boot.
|
||||
void crash_handler_log();
|
||||
|
||||
/// Returns true if crash data was found this boot, reading it first if needed.
|
||||
bool crash_handler_has_data();
|
||||
|
||||
} // namespace esphome::libretiny
|
||||
|
||||
#endif // USE_LIBRETINY_CRASH_HANDLER
|
||||
#endif // USE_BK72XX
|
||||
@@ -146,6 +146,11 @@ COMPONENT_CODEOWNERS = {
|
||||
"ln882x": ["@lamauny"],
|
||||
}
|
||||
|
||||
# Extra imports per component; the CLI looks up process_stacktrace on the platform package.
|
||||
COMPONENT_EXTRA_IMPORTS = {
|
||||
"bk72xx": "from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401",
|
||||
}
|
||||
|
||||
|
||||
def subst(code: str, key: str, value: str) -> str:
|
||||
return code.replace(f"{{{key}}}", value)
|
||||
@@ -222,6 +227,8 @@ def write_component_code(
|
||||
import_lines = "\n".join(
|
||||
f"from .{m} import {', '.join(sorted(v))}" for m, v in imports.items() if v
|
||||
)
|
||||
if extra := COMPONENT_EXTRA_IMPORTS.get(component):
|
||||
import_lines = f"{extra}\n{import_lines}"
|
||||
code = subst_many(
|
||||
code,
|
||||
("IMPORTS", import_lines),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# pylint: disable=E0602
|
||||
Import("env") # noqa
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
# Adds a .noinit section for the crash record (crash_handler.cpp) to the
|
||||
# BK7238 / BK7231N linker script, in ITCM after .itcm.code: ITCM survives
|
||||
# the watchdog reset and the SDK startup only copies .itcm.code there, while
|
||||
# the bootloader clears at least 128 KB of SRAM and the SDK's saved registers
|
||||
# on every boot (measured on a BK7238). The LMA stays in ITCM. BK7231T/Q/7251
|
||||
# have no ITCM region, so libretiny/__init__.py loads this for two families.
|
||||
|
||||
_MARKER = "/* esphome .noinit */"
|
||||
_ITCM_CODE_END = re.compile(r"(\}\s*>\s*itcm\s+AT\s*>\s*flash[^\n]*\n)")
|
||||
_NOINIT_BLOCK = (
|
||||
"\t.noinit (NOLOAD) : " + _MARKER + "\n"
|
||||
"\t{\n"
|
||||
"\t\t. = ALIGN(8);\n"
|
||||
"\t\tKEEP(*(.noinit .noinit.*))\n"
|
||||
"\t} > itcm AT> itcm\n"
|
||||
)
|
||||
|
||||
|
||||
def _pre_link(target, source, env):
|
||||
build_dir = env.subst("$BUILD_DIR")
|
||||
found = False
|
||||
for name in os.listdir(build_dir):
|
||||
if not name.endswith(".ld"):
|
||||
continue
|
||||
path = os.path.join(build_dir, name)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
if _MARKER in content:
|
||||
found = True
|
||||
continue
|
||||
new_content = _ITCM_CODE_END.sub(
|
||||
lambda m: m.group(1) + _NOINIT_BLOCK, content, count=1
|
||||
)
|
||||
if new_content == content:
|
||||
continue
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(new_content)
|
||||
print("ESPHome: patched {} (.noinit section)".format(name))
|
||||
found = True
|
||||
if not found:
|
||||
raise RuntimeError(
|
||||
"ESPHome: no .ld in {} got the .noinit section; the crash record "
|
||||
"would not survive a reboot. Update the regex in "
|
||||
"patch_noinit.py.script for this LibreTiny version.".format(build_dir)
|
||||
)
|
||||
|
||||
|
||||
ITCM_START = 0x003F0000
|
||||
ITCM_END = 0x00400000
|
||||
|
||||
|
||||
def _post_link(target, source, env):
|
||||
"""Fail unless the linked ELF keeps .noinit in ITCM (stale marker, renamed script)."""
|
||||
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
|
||||
objdump = re.sub(r"(gcc-)?nm$", "objdump", env.subst("$NM")) # same toolchain prefix
|
||||
headers = subprocess.check_output([objdump, "-h", elf], text=True)
|
||||
for line in headers.splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) > 3 and fields[1] == ".noinit":
|
||||
vma = int(fields[3], 16)
|
||||
if ITCM_START <= vma < ITCM_END:
|
||||
return
|
||||
raise RuntimeError(
|
||||
"ESPHome: .noinit landed at 0x{:08x}, outside ITCM; the crash "
|
||||
"record would not survive a reboot.".format(vma)
|
||||
)
|
||||
raise RuntimeError("ESPHome: the linked ELF has no .noinit section.")
|
||||
|
||||
|
||||
# LibreTiny renders the .ld into $BUILD_DIR after this script loads; patch at link time.
|
||||
env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link)
|
||||
env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link)
|
||||
@@ -2,6 +2,9 @@
|
||||
#include "logger.h"
|
||||
|
||||
#include "esphome/components/libretiny/core.h"
|
||||
#ifdef USE_LIBRETINY_CRASH_HANDLER
|
||||
#include "esphome/components/libretiny/crash_handler.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::logger {
|
||||
|
||||
@@ -52,6 +55,9 @@ void Logger::pre_setup() {
|
||||
|
||||
global_logger = this;
|
||||
ESP_LOGI(TAG, "Log initialized");
|
||||
#ifdef USE_LIBRETINY_CRASH_HANDLER
|
||||
libretiny::crash_handler_log();
|
||||
#endif
|
||||
}
|
||||
|
||||
const LogString *Logger::get_uart_selection_() {
|
||||
@@ -68,6 +74,11 @@ const LogString *Logger::get_uart_selection_() {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_LIBRETINY_CRASH_HANDLER
|
||||
bool crash_log_pending() { return libretiny::crash_handler_has_data(); }
|
||||
void log_crash_to_client() { libretiny::crash_handler_log(); }
|
||||
#endif
|
||||
|
||||
} // namespace esphome::logger
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
from string import ascii_letters, digits
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
@@ -34,6 +32,7 @@ from esphome.core import (
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401
|
||||
from esphome.storage_json import StorageJSON
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -654,48 +653,3 @@ def copy_files() -> None:
|
||||
path = CORE.relative_src_path("esphome.h")
|
||||
content = read_file(path).rstrip("\n")
|
||||
write_file_if_changed(path, content + '\n#include "pio_includes.h"\n')
|
||||
|
||||
|
||||
# RP2040 crash handler stacktrace decoding
|
||||
# Matches output from esphome/components/rp2/crash_handler.cpp
|
||||
_CRASH_RE = re.compile(r"CRASH DETECTED ON PREVIOUS BOOT")
|
||||
_CRASH_ADDR_RE = re.compile(
|
||||
r"(?:PC|LR|BT\d):\s+(0x[0-9a-fA-F]{8})\s+\((?:fault location|return address|stack backtrace)\)"
|
||||
)
|
||||
|
||||
|
||||
def _addr2line(tool: str, elf: Path, addr: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[tool, "-pfiaC", "-e", str(elf), addr],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return f"{addr} (decode failed)"
|
||||
|
||||
|
||||
def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool:
|
||||
"""Decode RP2040 crash handler output using addr2line."""
|
||||
if _CRASH_RE.search(line):
|
||||
_LOGGER.error("RP2040 crash detected - decoding addresses")
|
||||
return True
|
||||
|
||||
if backtrace_state:
|
||||
if match := _CRASH_ADDR_RE.search(line):
|
||||
from esphome.platformio.toolchain import get_idedata
|
||||
|
||||
idedata = get_idedata(config)
|
||||
if idedata.addr2line_path:
|
||||
elf = idedata.firmware_elf_path
|
||||
if elf.exists():
|
||||
decoded = _addr2line(idedata.addr2line_path, elf, match.group(1))
|
||||
_LOGGER.error(" %s => %s", match.group(1), decoded)
|
||||
|
||||
# Stop backtrace state after addr2line hint (last line of crash dump)
|
||||
if "addr2line" in line:
|
||||
return False
|
||||
|
||||
return backtrace_state
|
||||
|
||||
@@ -704,6 +704,9 @@
|
||||
#endif
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
#ifdef USE_BK72XX
|
||||
#define USE_LIBRETINY_CRASH_HANDLER
|
||||
#endif
|
||||
#define USE_BK72XX_BLE
|
||||
#define BK72XX_BLE_SCAN_LISTENER_COUNT 1
|
||||
#define USE_LN882H_BLE
|
||||
|
||||
@@ -23,6 +23,7 @@ import logging
|
||||
from typing import Any, Final
|
||||
|
||||
from esphome.const import (
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_ESP8266,
|
||||
PLATFORM_NRF52,
|
||||
@@ -37,6 +38,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# (upload method, log transport) warns. A new hook is loud by default.
|
||||
COSMETIC_HOOKS: Final = frozenset({"process_stacktrace"})
|
||||
|
||||
# Platforms whose firmware prints the crash handler report share one gate.
|
||||
_CRASH_HANDLER_GATE = r"0x[0-9a-fA-F]{3,}\b|CRASH DETECTED ON PREVIOUS BOOT"
|
||||
|
||||
# Per-platform trigger languages for lazy stacktrace decoding: a
|
||||
# matching line is what imports the platform package, so false triggers
|
||||
# (8-digit uptime counters, ESP-IDF decimal timestamps) must stay out.
|
||||
@@ -58,7 +62,8 @@ STACKTRACE_GATES: Final[dict[str, str]] = {
|
||||
r"|>>>stack>>>"
|
||||
r"|CRASH DETECTED ON PREVIOUS BOOT"
|
||||
),
|
||||
PLATFORM_RP2: r"0x[0-9a-fA-F]{3,}\b|CRASH DETECTED ON PREVIOUS BOOT",
|
||||
PLATFORM_RP2: _CRASH_HANDLER_GATE,
|
||||
PLATFORM_BK72XX: _CRASH_HANDLER_GATE,
|
||||
PLATFORM_NRF52: r"0x[0-9a-fA-F]{3,}\b|Last crash:",
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from esphome.types import ConfigType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
# The contract every platform's process_stacktrace implements.
|
||||
StacktraceHandler = Callable[[ConfigType, str, bool], bool]
|
||||
@@ -122,3 +123,65 @@ class LogLineProcessor:
|
||||
self._platform,
|
||||
detail,
|
||||
)
|
||||
|
||||
|
||||
# Crash handler report (rp2 and libretiny crash_handler.cpp): one address per line.
|
||||
CRASH_HANDLER_RE = re.compile(r"CRASH DETECTED ON PREVIOUS BOOT")
|
||||
CRASH_HANDLER_ADDR_RE = re.compile(
|
||||
r"(?:PC|LR|BT\d):\s+(0x[0-9a-fA-F]{8})\s+\((?:fault location|return address|stack backtrace)\)"
|
||||
)
|
||||
|
||||
|
||||
def _addr2line(tool: str, elf: Path, addr: str) -> str:
|
||||
# Lazy: __main__ imports this module on every CLI start
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[tool, "-pfiaC", "-e", str(elf), addr],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except (OSError, subprocess.CalledProcessError) as err:
|
||||
detail = getattr(err, "stderr", None) or str(err)
|
||||
return f"{addr} (decode failed: {detail.strip()})"
|
||||
|
||||
|
||||
def process_crash_handler_line(
|
||||
config: ConfigType, line: str, backtrace_state: bool
|
||||
) -> bool:
|
||||
"""Decode a crash handler report with addr2line; the process_stacktrace hook of rp2 and bk72xx."""
|
||||
if CRASH_HANDLER_RE.search(line):
|
||||
_LOGGER.error("Crash detected - decoding addresses")
|
||||
return True
|
||||
|
||||
if backtrace_state:
|
||||
if match := CRASH_HANDLER_ADDR_RE.search(line):
|
||||
from esphome.platformio.toolchain import get_idedata
|
||||
|
||||
addr = match.group(1)
|
||||
idedata = get_idedata(config)
|
||||
elf = idedata.firmware_elf_path
|
||||
# Warn once per report and leave the dump region
|
||||
if not idedata.addr2line_path:
|
||||
_LOGGER.warning(
|
||||
"Cannot decode the crash report: no addr2line in the toolchain"
|
||||
)
|
||||
return False
|
||||
if not elf.exists():
|
||||
_LOGGER.warning(
|
||||
"Cannot decode the crash report: %s missing, compile locally first",
|
||||
elf,
|
||||
)
|
||||
return False
|
||||
_LOGGER.error(
|
||||
" %s => %s", addr, _addr2line(idedata.addr2line_path, elf, addr)
|
||||
)
|
||||
|
||||
# The addr2line hint is the last line of the report
|
||||
if "addr2line" in line:
|
||||
return False
|
||||
|
||||
return backtrace_state
|
||||
|
||||
@@ -59,6 +59,7 @@ PLATFORM_BUILD_SECONDS = {
|
||||
"ln882x-ard": 55,
|
||||
"esp32-s3-idf": 60,
|
||||
"bk72xx-ard": 70,
|
||||
"bk7238-ard": 70,
|
||||
"esp32-c6-idf": 70,
|
||||
}
|
||||
NRF52_BUILD_SECONDS = 50
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7231t-qfn32-tuya
|
||||
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7238
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Only BK7238 and BK7231N, the BK chips with an ITCM linker region, get the crash handler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.libretiny import BK_TRAP_HANDLERS
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "enabled"),
|
||||
[
|
||||
("bk7238.yaml", True),
|
||||
("bk72xx.yaml", True), # BK7231N
|
||||
("bk7231t.yaml", False),
|
||||
("ln882x.yaml", False),
|
||||
],
|
||||
)
|
||||
def test_crash_handler_family_gate(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
config: str,
|
||||
enabled: bool,
|
||||
) -> None:
|
||||
generate_main(component_config_path(config))
|
||||
defines = {define.name for define in CORE.defines}
|
||||
assert ("USE_LIBRETINY_CRASH_HANDLER" in defines) is enabled
|
||||
assert ("USE_CRASH_HANDLER" in defines) is enabled
|
||||
for trap in BK_TRAP_HANDLERS:
|
||||
assert (f"-Wl,--wrap={trap}" in CORE.build_flags) is enabled
|
||||
assert (
|
||||
"pre:patch_noinit.py" in CORE.platformio_options["extra_scripts"]
|
||||
) is enabled
|
||||
@@ -0,0 +1,13 @@
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
- lambda: |-
|
||||
int x = 100;
|
||||
x = clamp(x, 50, 90);
|
||||
assert(x == 90);
|
||||
x = clamp_at_least(x, 95);
|
||||
assert(x == 95);
|
||||
x = clamp_at_most(x, 40);
|
||||
assert(x == 40);
|
||||
@@ -0,0 +1,15 @@
|
||||
esphome:
|
||||
name: componenttestespbk7238
|
||||
friendly_name: $component_name
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7238
|
||||
|
||||
logger:
|
||||
level: VERY_VERBOSE
|
||||
|
||||
packages:
|
||||
component_under_test: !include
|
||||
file: $component_test_file
|
||||
vars:
|
||||
component_test_file: $component_test_file
|
||||
@@ -117,6 +117,7 @@ from esphome.const import (
|
||||
PLATFORM_HOST,
|
||||
PLATFORM_NRF52,
|
||||
PLATFORM_RP2,
|
||||
PLATFORM_RTL87XX,
|
||||
ErrorFormat,
|
||||
Toolchain,
|
||||
)
|
||||
@@ -6143,7 +6144,7 @@ def test_run_miniterm_no_stacktrace_analyzer(
|
||||
"""Platforms without a stacktrace analyzer log an info and stream anyway."""
|
||||
mock_serial = MockSerial([b"[I][app:100]: Line 1\r\n", MOCK_SERIAL_END])
|
||||
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_BK72XX}
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_RTL87XX}
|
||||
config = {
|
||||
CONF_LOGGER: {
|
||||
CONF_BAUD_RATE: 115200,
|
||||
|
||||
@@ -15,7 +15,7 @@ from unittest.mock import Mock
|
||||
import pytest
|
||||
|
||||
from esphome import platform_hooks
|
||||
from esphome.const import PLATFORM_BK72XX, PLATFORM_ESP32, Platform
|
||||
from esphome.const import PLATFORM_ESP32, PLATFORM_RTL87XX, Platform
|
||||
|
||||
|
||||
def test_no_unregistered_platform_exposes_a_hook() -> None:
|
||||
@@ -165,7 +165,7 @@ def test_get_stacktrace_handler_reports_missing_analyzer(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level("INFO", logger="esphome.platform_hooks")
|
||||
assert platform_hooks.get_stacktrace_handler(PLATFORM_BK72XX) is None
|
||||
assert platform_hooks.get_stacktrace_handler(PLATFORM_RTL87XX) is None
|
||||
assert "no compatible analyzer" in caplog.text
|
||||
# A capability gap is ordinary; it must not warn.
|
||||
assert not any(r.levelno >= logging.WARNING for r in caplog.records)
|
||||
|
||||
@@ -6,6 +6,7 @@ import importlib
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from hypothesis import given, settings
|
||||
@@ -19,6 +20,7 @@ from esphome.const import (
|
||||
PLATFORM_ESP8266,
|
||||
PLATFORM_NRF52,
|
||||
PLATFORM_RP2,
|
||||
PLATFORM_RTL87XX,
|
||||
)
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
@@ -60,6 +62,10 @@ CRASH_SAMPLES: dict[str, dict[str, list[str]]] = {
|
||||
"state_markers": ["CRASH DETECTED ON PREVIOUS BOOT"],
|
||||
"addresses": ["PC: 0x10001234 (fault location)"],
|
||||
},
|
||||
PLATFORM_BK72XX: {
|
||||
"state_markers": ["CRASH DETECTED ON PREVIOUS BOOT"],
|
||||
"addresses": ["PC: 0x0001614A (fault location)"],
|
||||
},
|
||||
PLATFORM_NRF52: {
|
||||
"state_markers": ["Last crash:"],
|
||||
"addresses": [
|
||||
@@ -168,6 +174,7 @@ FIRMWARE_MARKER_SOURCES = {
|
||||
"esphome/components/esp32/crash_handler.cpp",
|
||||
"esphome/components/esp8266/crash_handler.cpp",
|
||||
"esphome/components/rp2/crash_handler.cpp",
|
||||
"esphome/components/libretiny/crash_handler.cpp",
|
||||
),
|
||||
"Last crash:": ("esphome/components/logger/logger_zephyr.cpp",),
|
||||
}
|
||||
@@ -222,7 +229,8 @@ DECODER_PATTERNS: dict[str, list[str]] = {
|
||||
"STACKTRACE_BAD_ALLOC_RE",
|
||||
"STACKTRACE_ESP8266_BACKTRACE_PC_RE",
|
||||
],
|
||||
PLATFORM_RP2: ["_CRASH_RE", "_CRASH_ADDR_RE"],
|
||||
PLATFORM_RP2: ["CRASH_HANDLER_RE", "CRASH_HANDLER_ADDR_RE"],
|
||||
PLATFORM_BK72XX: ["CRASH_HANDLER_RE", "CRASH_HANDLER_ADDR_RE"],
|
||||
PLATFORM_NRF52: ["STACKTRACE_NRF52_PC_LR_RE"],
|
||||
}
|
||||
|
||||
@@ -236,6 +244,12 @@ GATE_EXEMPT_PATTERNS = {
|
||||
}
|
||||
|
||||
|
||||
def _decoder_module(platform: str):
|
||||
"""Where a platform's decoder patterns live: its package, or its hook's module."""
|
||||
module = importlib.import_module(f"esphome.components.{platform}")
|
||||
return importlib.import_module(module.process_stacktrace.__module__)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", sorted(CRASH_SAMPLES))
|
||||
def test_platform_declarations_match_decoder(platform: str) -> None:
|
||||
r"""Samples, declared patterns, and the decoder must agree.
|
||||
@@ -253,9 +267,10 @@ def test_platform_declarations_match_decoder(platform: str) -> None:
|
||||
pointer branch's ``\b`` are invisible to it.
|
||||
"""
|
||||
module = importlib.import_module(f"esphome.components.{platform}")
|
||||
decoder = _decoder_module(platform)
|
||||
patterns: dict[str, re.Pattern] = {}
|
||||
for name in DECODER_PATTERNS[platform]:
|
||||
pattern = getattr(module, name, None)
|
||||
pattern = getattr(decoder, name, None)
|
||||
if pattern is None:
|
||||
pytest.fail(
|
||||
f"{platform} no longer defines {name}; update DECODER_PATTERNS "
|
||||
@@ -278,9 +293,9 @@ def test_platform_declarations_match_decoder(platform: str) -> None:
|
||||
)
|
||||
undeclared = [
|
||||
name
|
||||
for name, value in vars(module).items()
|
||||
for name, value in vars(decoder).items()
|
||||
if isinstance(value, re.Pattern)
|
||||
and ("STACKTRACE" in name or name.startswith("_CRASH"))
|
||||
and ("STACKTRACE" in name or name.startswith(("_CRASH", "CRASH_HANDLER")))
|
||||
and name not in DECODER_PATTERNS[platform]
|
||||
]
|
||||
assert not undeclared, (
|
||||
@@ -332,7 +347,7 @@ def test_address_gate_covers_decoder_pattern_languages(
|
||||
"""Each platform's gate must be a superset of its decoder patterns;
|
||||
generated inputs catch a widened decoder the finite samples miss.
|
||||
"""
|
||||
pattern = getattr(importlib.import_module(f"esphome.components.{platform}"), name)
|
||||
pattern = getattr(_decoder_module(platform), name)
|
||||
example = data.draw(from_regex(pattern, fullmatch=True))
|
||||
gate = re.compile(stacktrace.platform_hooks.STACKTRACE_GATES[platform])
|
||||
assert gate.search(example), (
|
||||
@@ -533,7 +548,7 @@ def test_processor_registry_miss_disables_at_construction(
|
||||
caplog.set_level("INFO", logger="esphome.platform_hooks")
|
||||
|
||||
with patch.object(stacktrace.platform_hooks, "import_module") as mock_import:
|
||||
processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_BK72XX)
|
||||
processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_RTL87XX)
|
||||
processor.process_line("PC: 0x40104960")
|
||||
|
||||
mock_import.assert_not_called()
|
||||
@@ -563,3 +578,91 @@ def test_external_platform_resolves_at_construction(
|
||||
|
||||
mock_import.assert_called_once()
|
||||
assert processor.backtrace_state is False
|
||||
|
||||
|
||||
# --- shared crash handler report decoder (rp2, bk72xx) ---
|
||||
|
||||
REPORT_PC_LINE = "[E][libretiny.crash:118]: PC: 0x0001D6DC (fault location)"
|
||||
REPORT_HINT_LINE = "Use: addr2line -pfiaC -e raw_firmware.elf 0x0001D6DC"
|
||||
|
||||
|
||||
def _idedata(
|
||||
tmp_path: Path, *, addr2line: str | None = "addr2line", elf: bool = True
|
||||
) -> Mock:
|
||||
idedata = Mock()
|
||||
idedata.addr2line_path = addr2line
|
||||
idedata.firmware_elf_path = tmp_path / "raw_firmware.elf"
|
||||
if elf:
|
||||
idedata.firmware_elf_path.write_bytes(b"")
|
||||
return idedata
|
||||
|
||||
|
||||
def test_crash_report_marker_opens_the_dump_region() -> None:
|
||||
assert stacktrace.process_crash_handler_line(
|
||||
CONFIG, "*** CRASH DETECTED ON PREVIOUS BOOT ***", False
|
||||
)
|
||||
|
||||
|
||||
def test_crash_report_lines_outside_the_region_are_ignored(
|
||||
mock_get_idedata: Mock,
|
||||
) -> None:
|
||||
assert not stacktrace.process_crash_handler_line(CONFIG, REPORT_PC_LINE, False)
|
||||
mock_get_idedata.assert_not_called()
|
||||
|
||||
|
||||
def test_crash_report_address_is_decoded(
|
||||
tmp_path: Path, mock_get_idedata: Mock, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
mock_get_idedata.return_value = _idedata(tmp_path)
|
||||
with patch.object(stacktrace, "_addr2line", return_value="main.cpp:12") as decode:
|
||||
assert stacktrace.process_crash_handler_line(CONFIG, REPORT_PC_LINE, True)
|
||||
decode.assert_called_once_with(
|
||||
"addr2line", tmp_path / "raw_firmware.elf", "0x0001D6DC"
|
||||
)
|
||||
assert "0x0001D6DC => main.cpp:12" in caplog.text
|
||||
|
||||
|
||||
def test_crash_report_hint_line_closes_the_region(mock_get_idedata: Mock) -> None:
|
||||
assert not stacktrace.process_crash_handler_line(CONFIG, REPORT_HINT_LINE, True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("addr2line", "elf", "reason"),
|
||||
[(None, True, "no addr2line"), ("addr2line", False, "missing")],
|
||||
)
|
||||
def test_crash_report_warns_once_when_it_cannot_decode(
|
||||
tmp_path: Path,
|
||||
mock_get_idedata: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
addr2line: str | None,
|
||||
elf: bool,
|
||||
reason: str,
|
||||
) -> None:
|
||||
mock_get_idedata.return_value = _idedata(tmp_path, addr2line=addr2line, elf=elf)
|
||||
# One warning per report
|
||||
assert not stacktrace.process_crash_handler_line(CONFIG, REPORT_PC_LINE, True)
|
||||
assert caplog.text.count(reason) == 1
|
||||
|
||||
|
||||
def test_addr2line_returns_the_tool_output(tmp_path: Path) -> None:
|
||||
elf = tmp_path / "fw.elf"
|
||||
with patch("subprocess.run", return_value=Mock(stdout=" decoded \n")) as run:
|
||||
assert stacktrace._addr2line("addr2line", elf, "0x1") == "decoded"
|
||||
assert run.call_args.args[0] == ["addr2line", "-pfiaC", "-e", str(elf), "0x1"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "detail"),
|
||||
[
|
||||
(subprocess.CalledProcessError(1, "addr2line", stderr="bad ELF\n"), "bad ELF"),
|
||||
(OSError("no such tool"), "no such tool"),
|
||||
],
|
||||
)
|
||||
def test_addr2line_reports_the_failure(
|
||||
tmp_path: Path, error: Exception, detail: str
|
||||
) -> None:
|
||||
with patch("subprocess.run", side_effect=error):
|
||||
assert (
|
||||
stacktrace._addr2line("addr2line", tmp_path / "fw.elf", "0x1")
|
||||
== f"0x1 (decode failed: {detail})"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user