Merge pull request #14834 from esphome/bump-2026.3.0b2

2026.3.0b2
This commit is contained in:
Jesse Hills
2026-03-16 11:45:42 +13:00
committed by GitHub
93 changed files with 1598 additions and 214 deletions

View File

@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.3.0b1
PROJECT_NUMBER = 2026.3.0b2
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a

View File

@@ -8,6 +8,13 @@
#endif // CYW43_USES_VSYS_PIN
#include <hardware/adc.h>
// PICO_VSYS_PIN is defined in pico-sdk board headers (e.g. boards/pico2.h),
// but the Arduino framework's config_autogen.h includes a generic board header
// that doesn't define it. Provide the standard value (pin 29) as a fallback.
#ifndef PICO_VSYS_PIN
#define PICO_VSYS_PIN 29 // NOLINT(cppcoreguidelines-macro-usage)
#endif
namespace esphome {
namespace adc {

View File

@@ -33,7 +33,7 @@ class AddressableLightDisplay : public display::DisplayBuffer {
// - Save the current effect index.
this->last_effect_index_ = light_state_->get_current_effect_index();
// - Disable any current effect.
light_state_->make_call().set_effect(0).perform();
light_state_->make_call().set_effect(uint32_t{0}).perform();
}
}
enabled_ = enabled;

View File

@@ -14,6 +14,12 @@
#include "api_server.h"
#include "esphome/core/application.h"
#include "esphome/core/component.h"
#ifdef USE_ESP32_CRASH_HANDLER
#include "esphome/components/esp32/crash_handler.h"
#endif
#ifdef USE_RP2040_CRASH_HANDLER
#include "esphome/components/rp2040/crash_handler.h"
#endif
#include "esphome/core/entity_base.h"
#include "esphome/core/string_ref.h"
@@ -235,6 +241,12 @@ class APIConnection final : public APIServerConnectionBase {
this->flags_.log_subscription = msg.level;
if (msg.dump_config)
App.schedule_dump_config();
#ifdef USE_ESP32_CRASH_HANDLER
esp32::crash_handler_log();
#endif
#ifdef USE_RP2040_CRASH_HANDLER
rp2040::crash_handler_log();
#endif
}
#ifdef USE_API_HOMEASSISTANT_SERVICES
void on_subscribe_homeassistant_services_request() override { this->flags_.service_call_subscription = true; }

View File

@@ -134,12 +134,16 @@ class APIFrameHelper {
//
// For log messages: Use Nagle to coalesce multiple small log packets into
// fewer larger packets, reducing WiFi overhead. However, we limit batching
// to 3 messages to avoid excessive LWIP buffer pressure on memory-constrained
// devices like ESP8266. LWIP's TCP_OVERSIZE option coalesces the data into
// shared pbufs, but holding data too long waiting for Nagle's timer causes
// buffer exhaustion and dropped messages.
// to avoid excessive LWIP buffer pressure on memory-constrained devices.
// LWIP's TCP_OVERSIZE option coalesces the data into shared pbufs, but
// holding data too long waiting for Nagle's timer causes buffer exhaustion
// and dropped messages.
//
// Flow: Log 1 (Nagle on) -> Log 2 (Nagle on) -> Log 3 (NODELAY, flush all)
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle
// ESP8266 (2×MSS): 3 logs per cycle (tightest buffers)
//
// Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush)
// Flow (ESP8266): Log 1 (Nagle on) -> Log 2 -> Log 3 (NODELAY, flush all)
//
void set_nodelay_for_message(bool is_log_message) {
if (!is_log_message) {
@@ -150,7 +154,7 @@ class APIFrameHelper {
return;
}
// Log messages 1-3: state transitions -1 -> 1 -> 2 -> -1 (flush on 3rd)
// Log messages: state transitions -1 -> 1 -> ... -> LOG_NAGLE_COUNT -> -1 (flush)
if (this->nodelay_state_ == NODELAY_ON) {
this->set_nodelay_raw_(false);
this->nodelay_state_ = 1;
@@ -255,10 +259,16 @@ class APIFrameHelper {
uint8_t tx_buf_tail_{0};
uint8_t tx_buf_count_{0};
// Nagle batching state for log messages. NODELAY_ON (-1) means NODELAY is enabled
// (immediate send). Values 1-2 count log messages in the current Nagle batch.
// (immediate send). Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch.
// After LOG_NAGLE_COUNT logs, we switch to NODELAY to flush and reset.
// ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching.
// ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more.
static constexpr int8_t NODELAY_ON = -1;
#ifdef USE_ESP8266
static constexpr int8_t LOG_NAGLE_COUNT = 2;
#else
static constexpr int8_t LOG_NAGLE_COUNT = 3;
#endif
int8_t nodelay_state_{NODELAY_ON};
// Internal helper to set TCP_NODELAY socket option

View File

@@ -258,10 +258,13 @@ APIError APINoiseFrameHelper::state_action_() {
// ignore contents, may be used in future for flags
// Resize for: existing prologue + 2 size bytes + frame data
size_t old_size = this->prologue_.size();
this->prologue_.resize(old_size + 2 + this->rx_buf_.size());
this->prologue_[old_size] = (uint8_t) (this->rx_buf_.size() >> 8);
this->prologue_[old_size + 1] = (uint8_t) this->rx_buf_.size();
std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), this->rx_buf_.size());
size_t rx_size = this->rx_buf_.size();
this->prologue_.resize(old_size + 2 + rx_size);
this->prologue_[old_size] = (uint8_t) (rx_size >> 8);
this->prologue_[old_size + 1] = (uint8_t) rx_size;
if (rx_size > 0) {
std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size);
}
state_ = State::SERVER_HELLO;
}

View File

@@ -13,7 +13,7 @@ namespace esphome::api {
static inline void append_quoted_string(DumpBuffer &out, const StringRef &ref) {
out.append("'");
if (!ref.empty()) {
out.append(ref.c_str());
out.append(ref.c_str(), ref.size());
}
out.append("'");
}

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
from datetime import datetime
import importlib
import logging
from typing import TYPE_CHECKING, Any
import warnings
@@ -18,6 +19,7 @@ import contextlib
from esphome.const import CONF_KEY, CONF_PORT, __version__
from esphome.core import CORE
from esphome.platformio_api import process_stacktrace
from . import CONF_ENCRYPTION
@@ -55,9 +57,19 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None:
addresses=addresses, # Pass all addresses for automatic retry
)
dashboard = CORE.dashboard
backtrace_state = False
# Try platform-specific stacktrace handler first, fall back to generic
platform_process_stacktrace = None
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
platform_process_stacktrace = getattr(module, "process_stacktrace")
except (AttributeError, ImportError):
pass
def on_log(msg: SubscribeLogsResponse) -> None:
"""Handle a new log message."""
nonlocal backtrace_state
time_ = datetime.now()
message: bytes = msg.message
text = message.decode("utf8", "backslashreplace")
@@ -67,6 +79,15 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None:
)
for parsed_msg in parse_log_message(text, timestamp):
print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg)
for raw_line in text.splitlines():
if platform_process_stacktrace:
backtrace_state = platform_process_stacktrace(
config, raw_line, backtrace_state
)
else:
backtrace_state = process_stacktrace(
config, raw_line, backtrace_state=backtrace_state
)
stop = await async_run(cli, on_log, name=name)
try:

View File

@@ -214,4 +214,4 @@ async def to_code(config):
cg.add_define("USE_AUDIO_MP3_SUPPORT")
if data.opus_support:
cg.add_define("USE_AUDIO_OPUS_SUPPORT")
add_idf_component(name="esphome/micro-opus", ref="0.3.4")
add_idf_component(name="esphome/micro-opus", ref="0.3.5")

View File

@@ -61,7 +61,7 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) {
// Defer save to main loop thread to avoid NVS operations from HTTP thread
this->defer([ssid, psk]() { wifi::global_wifi_component->save_wifi_sta(ssid.c_str(), psk.c_str()); });
#endif
request->redirect(ESPHOME_F("/?save"));
request->send(200, ESPHOME_F("text/plain"), ESPHOME_F("Saved. Connecting..."));
}
void CaptivePortal::setup() {
@@ -71,7 +71,7 @@ void CaptivePortal::setup() {
void CaptivePortal::start() {
this->base_->init();
if (!this->initialized_) {
this->base_->add_handler(this);
this->base_->add_handler_without_auth(this);
}
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();

View File

@@ -18,6 +18,7 @@ namespace debug {
static constexpr size_t DEVICE_INFO_BUFFER_SIZE = 256;
static constexpr size_t RESET_REASON_BUFFER_SIZE = 128;
static constexpr size_t WAKEUP_CAUSE_BUFFER_SIZE = 128;
// buf_append_printf is now provided by esphome/core/helpers.h
@@ -94,7 +95,7 @@ class DebugComponent : public PollingComponent {
#endif // USE_TEXT_SENSOR
const char *get_reset_reason_(std::span<char, RESET_REASON_BUFFER_SIZE> buffer);
const char *get_wakeup_cause_(std::span<char, RESET_REASON_BUFFER_SIZE> buffer);
const char *get_wakeup_cause_(std::span<char, WAKEUP_CAUSE_BUFFER_SIZE> buffer);
uint32_t get_free_heap_();
size_t get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE> buffer, size_t pos);
void update_platform_();

View File

@@ -98,7 +98,7 @@ static const char *const WAKEUP_CAUSES[] = {
"BT",
};
const char *DebugComponent::get_wakeup_cause_(std::span<char, RESET_REASON_BUFFER_SIZE> buffer) {
const char *DebugComponent::get_wakeup_cause_(std::span<char, WAKEUP_CAUSE_BUFFER_SIZE> buffer) {
const char *wake_reason;
unsigned reason = esp_sleep_get_wakeup_cause();
if (reason < sizeof(WAKEUP_CAUSES) / sizeof(WAKEUP_CAUSES[0])) {
@@ -196,9 +196,10 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
uint32_t cpu_freq_mhz = arch_get_cpu_freq_hz() / 1000000;
pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32 " MHz", cpu_freq_mhz);
char reason_buffer[RESET_REASON_BUFFER_SIZE];
const char *reset_reason = get_reset_reason_(std::span<char, RESET_REASON_BUFFER_SIZE>(reason_buffer));
const char *wakeup_cause = get_wakeup_cause_(std::span<char, RESET_REASON_BUFFER_SIZE>(reason_buffer));
char reset_buffer[RESET_REASON_BUFFER_SIZE];
char wakeup_buffer[WAKEUP_CAUSE_BUFFER_SIZE];
const char *reset_reason = get_reset_reason_(std::span<char, RESET_REASON_BUFFER_SIZE>(reset_buffer));
const char *wakeup_cause = get_wakeup_cause_(std::span<char, WAKEUP_CAUSE_BUFFER_SIZE>(wakeup_buffer));
uint8_t mac[6];
get_mac_address_raw(mac);

View File

@@ -91,7 +91,7 @@ const char *DebugComponent::get_reset_reason_(std::span<char, RESET_REASON_BUFFE
return buffer.data();
}
const char *DebugComponent::get_wakeup_cause_(std::span<char, RESET_REASON_BUFFER_SIZE> buffer) {
const char *DebugComponent::get_wakeup_cause_(std::span<char, WAKEUP_CAUSE_BUFFER_SIZE> buffer) {
// ESP8266 doesn't have detailed wakeup cause like ESP32
return "";
}

View File

@@ -7,7 +7,7 @@ namespace debug {
const char *DebugComponent::get_reset_reason_(std::span<char, RESET_REASON_BUFFER_SIZE> buffer) { return ""; }
const char *DebugComponent::get_wakeup_cause_(std::span<char, RESET_REASON_BUFFER_SIZE> buffer) { return ""; }
const char *DebugComponent::get_wakeup_cause_(std::span<char, WAKEUP_CAUSE_BUFFER_SIZE> buffer) { return ""; }
uint32_t DebugComponent::get_free_heap_() { return INT_MAX; }

View File

@@ -12,7 +12,7 @@ const char *DebugComponent::get_reset_reason_(std::span<char, RESET_REASON_BUFFE
return lt_get_reboot_reason_name(lt_get_reboot_reason());
}
const char *DebugComponent::get_wakeup_cause_(std::span<char, RESET_REASON_BUFFER_SIZE> buffer) { return ""; }
const char *DebugComponent::get_wakeup_cause_(std::span<char, WAKEUP_CAUSE_BUFFER_SIZE> buffer) { return ""; }
uint32_t DebugComponent::get_free_heap_() { return lt_heap_get_free(); }

View File

@@ -1,23 +1,81 @@
#include "debug_component.h"
#ifdef USE_RP2040
#include "esphome/core/defines.h"
#include "esphome/core/log.h"
#include <Arduino.h>
#include <hardware/watchdog.h>
#if defined(PICO_RP2350)
#include <hardware/structs/powman.h>
#else
#include <hardware/structs/vreg_and_chip_reset.h>
#endif
#ifdef USE_RP2040_CRASH_HANDLER
#include "esphome/components/rp2040/crash_handler.h"
#endif
namespace esphome {
namespace debug {
static const char *const TAG = "debug";
const char *DebugComponent::get_reset_reason_(std::span<char, RESET_REASON_BUFFER_SIZE> buffer) { return ""; }
const char *DebugComponent::get_reset_reason_(std::span<char, RESET_REASON_BUFFER_SIZE> buffer) {
char *buf = buffer.data();
const size_t size = RESET_REASON_BUFFER_SIZE;
size_t pos = 0;
const char *DebugComponent::get_wakeup_cause_(std::span<char, RESET_REASON_BUFFER_SIZE> buffer) { return ""; }
#if defined(PICO_RP2350)
uint32_t chip_reset = powman_hw->chip_reset;
if (chip_reset & 0x04000000) // HAD_GLITCH_DETECT
pos = buf_append_str(buf, size, pos, "Power supply glitch|");
if (chip_reset & 0x00040000) // HAD_RUN_LOW
pos = buf_append_str(buf, size, pos, "RUN pin|");
if (chip_reset & 0x00020000) // HAD_BOR
pos = buf_append_str(buf, size, pos, "Brown-out|");
if (chip_reset & 0x00010000) // HAD_POR
pos = buf_append_str(buf, size, pos, "Power-on reset|");
#else
uint32_t chip_reset = vreg_and_chip_reset_hw->chip_reset;
if (chip_reset & 0x00010000) // HAD_RUN
pos = buf_append_str(buf, size, pos, "RUN pin|");
if (chip_reset & 0x00000100) // HAD_POR
pos = buf_append_str(buf, size, pos, "Power-on reset|");
#endif
uint32_t DebugComponent::get_free_heap_() { return rp2040.getFreeHeap(); }
if (watchdog_caused_reboot()) {
bool handled = false;
#ifdef USE_RP2040_CRASH_HANDLER
if (rp2040::crash_handler_has_data()) {
pos = buf_append_str(buf, size, pos, "Crash (HardFault)|");
handled = true;
}
#endif
if (!handled) {
if (watchdog_enable_caused_reboot()) {
pos = buf_append_str(buf, size, pos, "Watchdog timeout|");
} else {
pos = buf_append_str(buf, size, pos, "Software reset|");
}
}
}
// Remove trailing '|'
if (pos > 0 && buf[pos - 1] == '|') {
buf[pos - 1] = '\0';
} else if (pos == 0) {
return "Unknown";
}
return buf;
}
const char *DebugComponent::get_wakeup_cause_(std::span<char, WAKEUP_CAUSE_BUFFER_SIZE> buffer) { return ""; }
uint32_t DebugComponent::get_free_heap_() { return ::rp2040.getFreeHeap(); }
size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE> buffer, size_t pos) {
constexpr size_t size = DEVICE_INFO_BUFFER_SIZE;
char *buf = buffer.data();
uint32_t cpu_freq = rp2040.f_cpu();
uint32_t cpu_freq = ::rp2040.f_cpu();
ESP_LOGD(TAG, "CPU Frequency: %" PRIu32, cpu_freq);
pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32, cpu_freq);

View File

@@ -53,7 +53,7 @@ const char *DebugComponent::get_reset_reason_(std::span<char, RESET_REASON_BUFFE
return buf;
}
const char *DebugComponent::get_wakeup_cause_(std::span<char, RESET_REASON_BUFFER_SIZE> buffer) {
const char *DebugComponent::get_wakeup_cause_(std::span<char, WAKEUP_CAUSE_BUFFER_SIZE> buffer) {
// Zephyr doesn't have detailed wakeup cause like ESP32
return "";
}

View File

@@ -1442,6 +1442,11 @@ async def to_code(config):
cg.add_build_flag("-DUSE_ESP32")
cg.add_define("USE_NATIVE_64BIT_TIME")
cg.add_build_flag("-Wl,-z,noexecstack")
# Arduino already wraps esp_panic_handler for its own backtrace handler,
# so only add our wrap when using ESP-IDF framework to avoid linker conflicts.
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
variant = config[CONF_VARIANT]
cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")

View File

@@ -1,6 +1,7 @@
#ifdef USE_ESP32
#include "esphome/core/defines.h"
#include "crash_handler.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "preferences.h"
@@ -36,6 +37,11 @@ void arch_restart() {
}
void arch_init() {
#ifdef USE_ESP32_CRASH_HANDLER
// Read crash data from previous boot before anything else
esp32::crash_handler_read_and_clear();
#endif
// Enable the task watchdog only on the loop task (from which we're currently running)
esp_task_wdt_add(nullptr);

View File

@@ -0,0 +1,355 @@
#ifdef USE_ESP32
#include "esphome/core/defines.h"
#ifdef USE_ESP32_CRASH_HANDLER
#include "crash_handler.h"
#include "esphome/core/log.h"
#include <cinttypes>
#include <cstring>
#include <esp_attr.h>
#include <esp_private/panic_internal.h>
#include <soc/soc.h>
#if CONFIG_IDF_TARGET_ARCH_XTENSA
#include <esp_cpu_utils.h>
#include <esp_debug_helpers.h>
#include <xtensa_context.h>
#elif CONFIG_IDF_TARGET_ARCH_RISCV
#include <riscv/rvruntime-frames.h>
#endif
static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF;
static constexpr size_t MAX_BACKTRACE = 16;
// Check if an address looks like code (flash-mapped or IRAM).
// Must be safe to call from panic context (no flash access needed).
static inline bool IRAM_ATTR is_code_addr(uint32_t addr) {
return (addr >= SOC_IROM_LOW && addr < SOC_IROM_HIGH) || (addr >= SOC_IRAM_LOW && addr < SOC_IRAM_HIGH);
}
#if CONFIG_IDF_TARGET_ARCH_RISCV
// Check if a code address is a real return address by verifying the preceding
// instruction is a JAL or JALR with rd=ra (x1). Called at log time (not during
// panic) so flash cache is available and both IRAM and IROM are safely readable.
static inline bool is_return_addr(uint32_t addr) {
if (!is_code_addr(addr) || addr < 4)
return false;
// A return address on the stack points to the instruction after a call.
// Check for 4-byte JAL/JALR call instruction before this address.
// Use memcpy for alignment safety — RISC-V C extension means code addresses
// are only 2-byte aligned, so addr-4 may not be 4-byte aligned.
uint32_t inst;
memcpy(&inst, (const void *) (addr - 4), sizeof(inst));
// RISC-V instruction encoding: bits [6:0] = opcode, bits [11:7] = rd
uint32_t opcode = inst & 0x7f; // Extract 7-bit opcode
uint32_t rd = inst & 0xf80; // Extract rd field (bits 11:7)
// Match JAL (0x6f) or JALR (0x67) with rd=ra (x1, encoded as 0x80 = 1<<7)
if ((opcode == 0x6f || opcode == 0x67) && rd == 0x80)
return true;
// Check for 2-byte compressed c.jalr before this address (C extension).
// c.jalr saves to ra implicitly: funct4=1001, rs1!=0, rs2=0, op=10
if (addr >= 2) {
uint16_t c_inst = *(uint16_t *) (addr - 2);
if ((c_inst & 0xf07f) == 0x9002 && (c_inst & 0x0f80) != 0)
return true;
}
return false;
}
#endif
// Raw crash data written by the panic handler wrapper.
// Lives in .noinit so it survives software reset but contains garbage after power cycle.
// Validated by magic marker. Static linkage since it's only used within this file.
// Version field is first so future firmware can always identify the struct layout.
// Magic is second to validate the data. Remaining fields can change between versions.
// Version is uint32_t because it would be padded to 4 bytes anyway before the next
// uint32_t field, so we use the full width rather than wasting 3 bytes of padding.
static constexpr uint32_t CRASH_DATA_VERSION = 1;
struct RawCrashData {
uint32_t version;
uint32_t magic;
uint32_t pc;
uint8_t backtrace_count;
uint8_t reg_frame_count; // Number of entries from registers (not stack-scanned)
uint8_t exception; // panic_exception_t enum (FAULT/ABORT/IWDT/TWDT/DEBUG)
uint8_t pseudo_excause; // Whether cause is a pseudo exception (Xtensa SoC-level panic)
uint32_t backtrace[MAX_BACKTRACE];
uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V)
};
static RawCrashData __attribute__((section(".noinit")))
s_raw_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// Whether crash data was found and validated this boot.
static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
namespace esphome::esp32 {
static const char *const TAG = "esp32.crash";
void crash_handler_read_and_clear() {
if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) {
s_crash_data_valid = true;
// Clamp counts to prevent out-of-bounds reads from corrupt .noinit data
if (s_raw_crash_data.backtrace_count > MAX_BACKTRACE)
s_raw_crash_data.backtrace_count = MAX_BACKTRACE;
if (s_raw_crash_data.reg_frame_count > s_raw_crash_data.backtrace_count)
s_raw_crash_data.reg_frame_count = s_raw_crash_data.backtrace_count;
if (s_raw_crash_data.exception > 4) // panic_exception_t max value
s_raw_crash_data.exception = 4; // Default to PANIC_EXCEPTION_FAULT
if (s_raw_crash_data.pseudo_excause > 1)
s_raw_crash_data.pseudo_excause = 0;
}
// Clear magic regardless so we don't re-report on next normal reboot
s_raw_crash_data.magic = 0;
}
bool crash_handler_has_data() { return s_crash_data_valid; }
// Look up the exception cause as a human-readable string.
// Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays
// not exposed via any public API.
static const char *get_exception_reason() {
#if CONFIG_IDF_TARGET_ARCH_XTENSA
if (s_raw_crash_data.pseudo_excause) {
// SoC-level panic: watchdog, cache error, etc.
// Keep in sync with ESP-IDF's PANIC_RSN_* defines
static const char *const PSEUDO_REASON[] = {
"Unknown reason", // 0
"Unhandled debug exception", // 1
"Double exception", // 2
"Unhandled kernel exception", // 3
"Coprocessor exception", // 4
"Interrupt wdt timeout on CPU0", // 5
"Interrupt wdt timeout on CPU1", // 6
"Cache error", // 7
};
uint32_t cause = s_raw_crash_data.cause;
if (cause < sizeof(PSEUDO_REASON) / sizeof(PSEUDO_REASON[0]))
return PSEUDO_REASON[cause];
return PSEUDO_REASON[0];
}
// Real Xtensa exception
static const char *const REASON[] = {
"IllegalInstruction",
"Syscall",
"InstructionFetchError",
"LoadStoreError",
"Level1Interrupt",
"Alloca",
"IntegerDivideByZero",
"PCValue",
"Privileged",
"LoadStoreAlignment",
nullptr,
nullptr,
"InstrPDAddrError",
"LoadStorePIFDataError",
"InstrPIFAddrError",
"LoadStorePIFAddrError",
"InstTLBMiss",
"InstTLBMultiHit",
"InstFetchPrivilege",
nullptr,
"InstrFetchProhibited",
nullptr,
nullptr,
nullptr,
"LoadStoreTLBMiss",
"LoadStoreTLBMultihit",
"LoadStorePrivilege",
nullptr,
"LoadProhibited",
"StoreProhibited",
};
uint32_t cause = s_raw_crash_data.cause;
if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr)
return REASON[cause];
#elif CONFIG_IDF_TARGET_ARCH_RISCV
// For SoC-level panics (watchdog, cache error), mcause holds IDF-internal
// interrupt numbers, not standard RISC-V cause codes. The exception type
// field already identifies these, so just return null to use the type name.
if (s_raw_crash_data.pseudo_excause)
return nullptr;
static const char *const REASON[] = {
"Instruction address misaligned",
"Instruction access fault",
"Illegal instruction",
"Breakpoint",
"Load address misaligned",
"Load access fault",
"Store address misaligned",
"Store access fault",
"Environment call from U-mode",
"Environment call from S-mode",
nullptr,
"Environment call from M-mode",
"Instruction page fault",
"Load page fault",
nullptr,
"Store page fault",
};
uint32_t cause = s_raw_crash_data.cause;
if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr)
return REASON[cause];
#endif
return "Unknown";
}
// Exception type names matching panic_exception_t enum
static const char *get_exception_type() {
static const char *const TYPES[] = {
"Debug exception", // PANIC_EXCEPTION_DEBUG
"Interrupt wdt", // PANIC_EXCEPTION_IWDT
"Task wdt", // PANIC_EXCEPTION_TWDT
"Abort", // PANIC_EXCEPTION_ABORT
"Fault", // PANIC_EXCEPTION_FAULT
};
uint8_t exc = s_raw_crash_data.exception;
if (exc < sizeof(TYPES) / sizeof(TYPES[0]))
return TYPES[exc];
return "Unknown";
}
// Intentionally uses separate ESP_LOGE calls per line instead of combining into
// one multi-line log message. This ensures each address appears as its own line
// on the serial console, making it possible to see partial output if the device
// crashes again during boot, and allowing the CLI's process_stacktrace to match
// and decode each address individually.
void crash_handler_log() {
if (!s_crash_data_valid)
return;
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
const char *reason = get_exception_reason();
if (reason != nullptr) {
ESP_LOGE(TAG, " Reason: %s - %s", get_exception_type(), reason);
} else {
ESP_LOGE(TAG, " Reason: %s", get_exception_type());
}
ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc);
uint8_t bt_num = 0;
for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count; i++) {
uint32_t addr = s_raw_crash_data.backtrace[i];
#if CONFIG_IDF_TARGET_ARCH_RISCV
// Register-sourced entries (MEPC/RA) are trusted; only filter stack-scanned ones.
if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr))
continue;
#endif
#if CONFIG_IDF_TARGET_ARCH_RISCV
const char *source = (i < s_raw_crash_data.reg_frame_count) ? "backtrace" : "stack scan";
#else
const char *source = "backtrace";
#endif
ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (%s)", bt_num++, addr, source);
}
// Build addr2line hint with all captured addresses for easy copy-paste
char hint[256];
int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc);
for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count && pos < (int) sizeof(hint) - 12; i++) {
uint32_t addr = s_raw_crash_data.backtrace[i];
#if CONFIG_IDF_TARGET_ARCH_RISCV
if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr))
continue;
#endif
pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, addr);
}
ESP_LOGE(TAG, "%s", hint);
}
} // namespace esphome::esp32
// --- Panic handler wrapper ---
// Intercepts esp_panic_handler() via --wrap linker flag to capture crash data
// into NOINIT memory before the normal panic handler runs.
//
extern "C" {
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
// Names are mandated by the --wrap linker mechanism
extern void __real_esp_panic_handler(panic_info_t *info);
void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
// Save the faulting PC and exception info
s_raw_crash_data.pc = (uint32_t) info->addr;
s_raw_crash_data.backtrace_count = 0;
s_raw_crash_data.reg_frame_count = 0;
s_raw_crash_data.exception = (uint8_t) info->exception;
s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0;
#if CONFIG_IDF_TARGET_ARCH_XTENSA
// Xtensa: walk the backtrace using the public API
if (info->frame != nullptr) {
auto *xt_frame = (XtExcFrame *) info->frame;
s_raw_crash_data.cause = xt_frame->exccause;
esp_backtrace_frame_t bt_frame = {
.pc = (uint32_t) xt_frame->pc,
.sp = (uint32_t) xt_frame->a1,
.next_pc = (uint32_t) xt_frame->a0,
.exc_frame = xt_frame,
};
uint8_t count = 0;
// First frame PC
uint32_t first_pc = esp_cpu_process_stack_pc(bt_frame.pc);
if (is_code_addr(first_pc)) {
s_raw_crash_data.backtrace[count++] = first_pc;
}
// Walk remaining frames
while (count < MAX_BACKTRACE && bt_frame.next_pc != 0) {
if (!esp_backtrace_get_next_frame(&bt_frame)) {
break;
}
uint32_t pc = esp_cpu_process_stack_pc(bt_frame.pc);
if (is_code_addr(pc)) {
s_raw_crash_data.backtrace[count++] = pc;
}
}
s_raw_crash_data.backtrace_count = count;
}
#elif CONFIG_IDF_TARGET_ARCH_RISCV
// RISC-V: capture MEPC + RA, then scan stack for code addresses
if (info->frame != nullptr) {
auto *rv_frame = (RvExcFrame *) info->frame;
s_raw_crash_data.cause = rv_frame->mcause;
uint8_t count = 0;
// Save MEPC (fault PC) and RA (return address)
if (is_code_addr(rv_frame->mepc)) {
s_raw_crash_data.backtrace[count++] = rv_frame->mepc;
}
if (is_code_addr(rv_frame->ra) && rv_frame->ra != rv_frame->mepc) {
s_raw_crash_data.backtrace[count++] = rv_frame->ra;
}
// Track how many entries came from registers (MEPC/RA) so we can
// skip return-address validation for them at log time.
s_raw_crash_data.reg_frame_count = count;
// Scan stack for code addresses — captures broadly during panic,
// filtered by is_return_addr() at log time when flash is accessible.
auto *scan_start = (uint32_t *) rv_frame->sp;
for (uint32_t i = 0; i < 64 && count < MAX_BACKTRACE; i++) {
uint32_t val = scan_start[i];
if (is_code_addr(val) && val != rv_frame->mepc && val != rv_frame->ra) {
s_raw_crash_data.backtrace[count++] = val;
}
}
s_raw_crash_data.backtrace_count = count;
}
#endif
// Write version and magic last — ensures all data is written before we mark it valid
s_raw_crash_data.version = CRASH_DATA_VERSION;
s_raw_crash_data.magic = CRASH_MAGIC;
// Call the real panic handler (prints to UART, does core dump, reboots, etc.)
__real_esp_panic_handler(info);
}
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
} // extern "C"
#endif // USE_ESP32_CRASH_HANDLER
#endif // USE_ESP32

View File

@@ -0,0 +1,18 @@
#pragma once
#ifdef USE_ESP32_CRASH_HANDLER
namespace esphome::esp32 {
/// Read crash data from NOINIT memory and clear the magic marker.
void crash_handler_read_and_clear();
/// Log crash data if a crash was detected on previous boot.
void crash_handler_log();
/// Returns true if crash data was found this boot.
bool crash_handler_has_data();
} // namespace esphome::esp32
#endif // USE_ESP32_CRASH_HANDLER

View File

@@ -27,6 +27,7 @@ static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s
static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum)
static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms
static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s
static constexpr uint32_t DISCONNECTING_TIMEOUT = 10000; // 10s
static const esp_bt_uuid_t NOTIFY_DESC_UUID = {
.len = ESP_UUID_LEN_16,
.uuid =
@@ -62,6 +63,15 @@ void BLEClientBase::loop() {
// will enable it again when a connection is needed.
else if (this->state() == espbt::ClientState::IDLE) {
this->disable_loop();
} else if (this->state() == espbt::ClientState::DISCONNECTING &&
(millis() - this->disconnecting_started_) > DISCONNECTING_TIMEOUT) {
ESP_LOGE(TAG, "[%d] [%s] Timeout waiting for CLOSE_EVT after disconnect, forcing IDLE", this->connection_index_,
this->address_str_);
// release_services() must be called before set_idle_() — if we entered DISCONNECTING
// via unconditional_disconnect() (which doesn't call release_services()), and ESP-IDF
// never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call.
this->release_services();
this->set_idle_();
}
}
@@ -101,12 +111,16 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) {
#endif
void BLEClientBase::connect() {
// Prevent duplicate connection attempts
// Prevent duplicate connection attempts or connecting while still disconnecting
if (this->state() == espbt::ClientState::CONNECTING || this->state() == espbt::ClientState::CONNECTED ||
this->state() == espbt::ClientState::ESTABLISHED) {
ESP_LOGW(TAG, "[%d] [%s] Connection already in progress, state=%s", this->connection_index_, this->address_str_,
espbt::client_state_to_string(this->state()));
return;
} else if (this->state() == espbt::ClientState::DISCONNECTING) {
ESP_LOGW(TAG, "[%d] [%s] Cannot connect, still waiting for CLOSE_EVT to complete disconnect",
this->connection_index_, this->address_str_);
return;
}
ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_);
this->paired_ = false;
@@ -174,7 +188,7 @@ void BLEClientBase::unconditional_disconnect() {
this->set_address(0);
this->set_state(espbt::ClientState::IDLE);
} else {
this->set_state(espbt::ClientState::DISCONNECTING);
this->set_disconnecting_();
}
}
@@ -220,6 +234,7 @@ void BLEClientBase::log_connection_params_(const char *param_type) {
void BLEClientBase::handle_connection_result_(esp_err_t ret) {
if (ret) {
this->log_gattc_warning_("esp_ble_gattc_open", ret);
// Don't use set_idle_() here — CONNECT_EVT never fired so conn_id_ is still UNSET_CONN_ID.
this->set_state(espbt::ClientState::IDLE);
}
}
@@ -311,15 +326,16 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
}
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->log_gattc_warning_("Connection open", param->open.status);
this->set_state(espbt::ClientState::IDLE);
// Connection was never established so CLOSE_EVT may not follow
this->set_idle_();
break;
}
if (this->want_disconnect_) {
// Disconnect was requested after connecting started,
// but before the connection was established. Now that we have
// this->conn_id_ set, we can disconnect it.
// Don't reset conn_id_ here — CLOSE_EVT needs it to match and call set_idle_().
this->unconditional_disconnect();
this->conn_id_ = UNSET_CONN_ID;
break;
}
// MTU negotiation already started in ESP_GATTC_CONNECT_EVT
@@ -363,8 +379,22 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, this->address_str_,
param->disconnect.reason);
}
// For active disconnects (esp_ble_gattc_close), CLOSE_EVT arrives before
// DISCONNECT_EVT. If CLOSE_EVT already transitioned us to IDLE, don't go
// backwards to DISCONNECTING — the connection is already fully cleaned up.
if (this->state() == espbt::ClientState::IDLE) {
this->log_event_("DISCONNECT_EVT after CLOSE_EVT, already IDLE");
break;
}
// For passive disconnects (remote device disconnected or link lost),
// DISCONNECT_EVT arrives first. Don't transition to IDLE yet — wait for
// CLOSE_EVT to ensure the controller has fully freed resources (L2CAP
// channels, ATT resources, HCI connection handle). Transitioning to IDLE
// here would allow reconnection before cleanup is complete, causing the
// controller to reject the new connection (status=133) or crash with
// ASSERT_PARAM in lld_evt.c.
this->release_services();
this->set_state(espbt::ClientState::IDLE);
this->set_disconnecting_();
break;
}
@@ -387,8 +417,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
return false;
this->log_gattc_lifecycle_event_("CLOSE");
this->release_services();
this->set_state(espbt::ClientState::IDLE);
this->conn_id_ = UNSET_CONN_ID;
this->set_idle_();
break;
}
case ESP_GATTC_SEARCH_RES_EVT: {

View File

@@ -113,11 +113,14 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
esp_bd_addr_t remote_bda_; // 6 bytes
// Group 5: 2-byte types
// Group 5: 4-byte types
uint32_t disconnecting_started_{0};
// Group 6: 2-byte types
uint16_t conn_id_{UNSET_CONN_ID};
uint16_t mtu_{23};
// Group 6: 1-byte types and small enums
// Group 7: 1-byte types and small enums
esp_ble_addr_type_t remote_addr_type_{BLE_ADDR_TYPE_PUBLIC};
espbt::ConnectionType connection_type_{espbt::ConnectionType::V1};
uint8_t connection_index_;
@@ -137,6 +140,16 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void log_gattc_warning_(const char *operation, esp_err_t err);
void log_connection_params_(const char *param_type);
void handle_connection_result_(esp_err_t ret);
/// Transition to IDLE and reset conn_id — call when the connection is fully dead.
void set_idle_() {
this->set_state(espbt::ClientState::IDLE);
this->conn_id_ = UNSET_CONN_ID;
}
/// Transition to DISCONNECTING and start the safety timeout.
void set_disconnecting_() {
this->disconnecting_started_ = millis();
this->set_state(espbt::ClientState::DISCONNECTING);
}
// Compact error logging helpers to reduce flash usage
void log_error_(const char *message);
void log_error_(const char *message, int code);

View File

@@ -105,7 +105,7 @@ async def to_code(config):
if framework_ver >= cv.Version(5, 5, 0):
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.4.0")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.4")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.0")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.1")
else:
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")

View File

@@ -22,9 +22,7 @@ void Mutex::unlock() {}
IRAM_ATTR InterruptLock::InterruptLock() { state_ = xt_rsil(15); }
IRAM_ATTR InterruptLock::~InterruptLock() { xt_wsr_ps(state_); }
// ESP8266 doesn't support lwIP core locking, so this is a no-op
LwIPLock::LwIPLock() {}
LwIPLock::~LwIPLock() {}
// ESP8266 LwIPLock is defined inline as a no-op in helpers.h
void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
wifi_get_macaddr(STATION_IF, mac);

View File

@@ -18,6 +18,7 @@
#include <cerrno>
#include <cstdio>
#include <sys/time.h>
namespace esphome {
@@ -238,6 +239,31 @@ void ESPHomeOTAComponent::handle_data_() {
/// and reboots on success.
///
/// Authentication has already been handled in the non-blocking states AUTH_SEND/AUTH_READ.
///
/// Socket I/O strategy:
///
/// Before this function, the handshake states use non-blocking I/O:
/// read()/write() return immediately with EWOULDBLOCK if no data
/// loop() retries on next iteration (~16ms), no delay needed
///
/// This function switches to blocking mode with SO_RCVTIMEO/SO_SNDTIMEO:
///
/// Path | Wait mechanism | WDT strategy
/// --------------|------------------------|---------------------------
/// Main read | SO_RCVTIMEO (2s block) | feed_wdt() only, no delay
/// readall_() | SO_RCVTIMEO (2s block) | feed_wdt() + delay(0)
/// writeall_() | SO_SNDTIMEO (2s block) | feed_wdt() + delay(1)
///
/// readall_() uses delay(0) because SO_RCVTIMEO already waited — just yield.
/// writeall_() uses delay(1) because on raw TCP (ESP8266, RP2040) writes
/// never block (tcp_write returns immediately), so delay(1) prevents spinning.
///
/// Platform details:
/// BSD sockets (ESP32): setblocking(true) makes read/write block
/// lwip sockets (LT): setblocking(true) makes read/write block
/// Raw TCP (8266, RP2040): setblocking is no-op; SO_RCVTIMEO uses
/// socket_delay()/socket_wake() in read();
/// write() always returns immediately
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
bool update_started = false;
size_t total = 0;
@@ -249,6 +275,14 @@ void ESPHomeOTAComponent::handle_data_() {
size_t size_acknowledged = 0;
#endif
// Set socket timeouts and blocking mode (see strategy table above)
struct timeval tv;
tv.tv_sec = 2;
tv.tv_usec = 0;
this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
this->client_->setsockopt(SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
this->client_->setblocking(true);
// Acknowledge auth OK - 1 byte
this->write_byte_(ota::OTA_RESPONSE_AUTH_OK);
@@ -299,7 +333,8 @@ void ESPHomeOTAComponent::handle_data_() {
ssize_t read = this->client_->read(buf, requested);
if (read == -1) {
if (this->would_block_(errno)) {
this->yield_and_feed_watchdog_();
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
App.feed_wdt();
continue;
}
ESP_LOGW(TAG, "Read err %d", errno);
@@ -401,7 +436,9 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) {
} else {
at += read;
}
this->yield_and_feed_watchdog_();
// read() already waited via SO_RCVTIMEO, just yield without 1ms stall
App.feed_wdt();
delay(0);
}
return true;
@@ -422,10 +459,13 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) {
ESP_LOGW(TAG, "Write err %zu bytes, errno %d", len, errno);
return false;
}
// EWOULDBLOCK: on raw TCP writes never block, delay(1) prevents spinning
this->yield_and_feed_watchdog_();
} else {
at += written;
// write() may block up to SO_SNDTIMEO on BSD/lwip sockets, feed WDT
App.feed_wdt();
}
this->yield_and_feed_watchdog_();
}
return true;
}

View File

@@ -21,22 +21,6 @@
namespace esphome::ethernet {
#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2)
// work around IDF compile issue on P4 https://github.com/espressif/esp-idf/pull/15637
#ifdef USE_ESP32_VARIANT_ESP32P4
#undef ETH_ESP32_EMAC_DEFAULT_CONFIG
#define ETH_ESP32_EMAC_DEFAULT_CONFIG() \
{ \
.smi_gpio = {.mdc_num = 31, .mdio_num = 52}, .interface = EMAC_DATA_INTERFACE_RMII, \
.clock_config = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) 50}}, \
.dma_burst_len = ETH_DMA_BURST_LEN_32, .intr_priority = 0, \
.emac_dataif_gpio = \
{.rmii = {.tx_en_num = 49, .txd0_num = 34, .txd1_num = 35, .crs_dv_num = 28, .rxd0_num = 29, .rxd1_num = 30}}, \
.clock_config_out_in = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) -1}}, \
}
#endif
#endif
static const char *const TAG = "ethernet";
// PHY register size for hex logging
@@ -162,7 +146,7 @@ void EthernetComponent::setup() {
phy_config.phy_addr = this->phy_addr_;
phy_config.reset_gpio_num = this->power_pin_;
eth_esp32_emac_config_t esp32_emac_config = ETH_ESP32_EMAC_DEFAULT_CONFIG();
eth_esp32_emac_config_t esp32_emac_config = eth_esp32_emac_default_config();
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0)
esp32_emac_config.smi_gpio.mdc_num = this->mdc_pin_;
esp32_emac_config.smi_gpio.mdio_num = this->mdio_pin_;

View File

@@ -11,10 +11,15 @@
#include "esp_eth.h"
#include "esp_eth_mac.h"
#include "esp_eth_mac_esp.h"
#include "esp_netif.h"
#include "esp_mac.h"
#include "esp_idf_version.h"
#if CONFIG_ETH_USE_ESP32_EMAC
extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void);
#endif
namespace esphome::ethernet {
#ifdef USE_ETHERNET_IP_STATE_LISTENERS

View File

@@ -0,0 +1,10 @@
#include "esp_eth_mac_esp.h"
// ETH_ESP32_EMAC_DEFAULT_CONFIG() uses out-of-order designated initializers
// which are valid in C but not in C++. This wrapper allows C++ code to get
// the default config without replicating the macro's contents.
#if CONFIG_ETH_USE_ESP32_EMAC
eth_esp32_emac_config_t eth_esp32_emac_default_config(void) {
return (eth_esp32_emac_config_t) ETH_ESP32_EMAC_DEFAULT_CONFIG();
}
#endif

View File

@@ -93,11 +93,31 @@ def _bus_declare_type(value):
raise NotImplementedError
def _rp2040_i2c_controller(pin):
"""Return the I2C controller number (0 or 1) for a given RP2040/RP2350 GPIO pin.
See RP2040 datasheet Table 2 (section 1.4.3, "GPIO Functions"):
https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf
See RP2350 datasheet Table 7 (section 9.4, "Function Select"):
https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf
"""
return (pin // 2) % 2
def validate_config(config):
if CORE.is_esp32:
return cv.require_framework_version(
esp_idf=cv.Version(5, 4, 2), esp32_arduino=cv.Version(3, 2, 1)
)(config)
if CORE.is_rp2040:
sda_controller = _rp2040_i2c_controller(config[CONF_SDA])
scl_controller = _rp2040_i2c_controller(config[CONF_SCL])
if sda_controller != scl_controller:
raise cv.Invalid(
f"SDA pin GPIO{config[CONF_SDA]} is on I2C{sda_controller} but "
f"SCL pin GPIO{config[CONF_SCL]} is on I2C{scl_controller}. "
f"Both pins must be on the same I2C controller."
)
return config
@@ -146,6 +166,23 @@ def _final_validate(config):
full_config = fv.full_config.get()[CONF_I2C]
if CORE.using_zephyr and len(full_config) > 1:
raise cv.Invalid("Second i2c is not implemented on Zephyr yet")
if CORE.is_rp2040:
if len(full_config) > 2:
raise cv.Invalid(
"The maximum number of I2C interfaces for RP2040/RP2350 is 2"
)
if len(full_config) > 1:
controllers = [
_rp2040_i2c_controller(conf[CONF_SDA]) for conf in full_config
]
if len(set(controllers)) != len(controllers):
raise cv.Invalid(
"Multiple I2C buses are configured to use the same I2C controller. "
"Each bus must use pins on a different controller. "
"The I2C controller is determined by (gpio / 2) % 2: "
"even pin pairs (0-1, 4-5, 8-9, ...) use I2C0, "
"odd pin pairs (2-3, 6-7, 10-11, ...) use I2C1."
)
if CORE.is_esp32 and get_esp32_variant() in ESP32_I2C_CAPABILITIES:
variant = get_esp32_variant()
max_num = ESP32_I2C_CAPABILITIES[variant]["NUM"]

View File

@@ -20,12 +20,14 @@ void ArduinoI2CBus::setup() {
#if defined(USE_ESP8266)
wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory)
#elif defined(USE_RP2040)
static bool first = true;
if (first) {
// Select Wire instance based on pin assignment, not definition order.
// I2C controller = (gpio / 2) % 2: even pairs (0-1,4-5,...) → I2C0, odd pairs (2-3,6-7,...) → I2C1
// RP2040 datasheet Table 2 (section 1.4.3): https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf
// RP2350 datasheet Table 7 (section 9.4): https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf
if ((this->sda_pin_ / 2) % 2 == 0) {
wire_ = &Wire;
first = false;
} else {
wire_ = &Wire1; // NOLINT(cppcoreguidelines-owning-memory)
wire_ = &Wire1;
}
#endif

View File

@@ -5,6 +5,10 @@
#include <driver/ledc.h>
#include <cinttypes>
#include <esp_private/periph_ctrl.h>
#if !defined(SOC_LEDC_SUPPORT_FADE_STOP)
#include <hal/ledc_ll.h>
#endif
#define CLOCK_FREQUENCY 80e6f
@@ -16,10 +20,10 @@
static const uint8_t SETUP_ATTEMPT_COUNT_MAX = 5;
namespace esphome {
namespace ledc {
namespace esphome::ledc {
static const char *const TAG = "ledc.output";
static bool ledc_peripheral_reset_done = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static const int MAX_RES_BITS = LEDC_TIMER_BIT_MAX - 1;
#if SOC_LEDC_SUPPORT_HS_MODE
@@ -32,6 +36,28 @@ inline ledc_mode_t get_speed_mode(uint8_t channel) { return channel < 8 ? LEDC_H
inline ledc_mode_t get_speed_mode(uint8_t) { return LEDC_LOW_SPEED_MODE; }
#endif
#if !defined(SOC_LEDC_SUPPORT_FADE_STOP)
// Classic ESP32 (currently the only target without SOC_LEDC_SUPPORT_FADE_STOP) can block in
// ledc_ll_set_duty_start() while duty_start is set. We check the same conf1.duty_start bit here
// to defer updates and avoid entering IDF's unbounded wait loop.
//
// This intentionally depends on the classic ESP32 LEDC register layout used by IDF's own LL HAL.
// If another target without SOC_LEDC_SUPPORT_FADE_STOP is introduced, revisit this helper.
static_assert(
#if defined(CONFIG_IDF_TARGET_ESP32)
true,
#else
false,
#endif
"LEDC duty_start pending check assumes classic ESP32 register layout; "
"re-evaluate for this target");
static bool ledc_duty_update_pending(ledc_mode_t speed_mode, ledc_channel_t chan_num) {
auto *hw = LEDC_LL_GET_HW();
return hw->channel_group[speed_mode].channel[chan_num].conf1.duty_start != 0;
}
#endif
float ledc_max_frequency_for_bit_depth(uint8_t bit_depth) {
return static_cast<float>(CLOCK_FREQUENCY) / static_cast<float>(1 << bit_depth);
}
@@ -105,21 +131,40 @@ void LEDCOutput::write_state(float state) {
const uint32_t max_duty = (uint32_t(1) << this->bit_depth_) - 1;
const float duty_rounded = roundf(state * max_duty);
auto duty = static_cast<uint32_t>(duty_rounded);
if (duty == this->last_duty_) {
return;
}
ESP_LOGV(TAG, "Setting duty: %" PRIu32 " on channel %u", duty, this->channel_);
auto speed_mode = get_speed_mode(this->channel_);
auto chan_num = static_cast<ledc_channel_t>(this->channel_ % 8);
int hpoint = ledc_angle_to_htop(this->phase_angle_, this->bit_depth_);
if (duty == max_duty) {
ledc_stop(speed_mode, chan_num, 1);
this->last_duty_ = duty;
} else if (duty == 0) {
ledc_stop(speed_mode, chan_num, 0);
this->last_duty_ = duty;
} else {
#if !defined(SOC_LEDC_SUPPORT_FADE_STOP)
if (ledc_duty_update_pending(speed_mode, chan_num)) {
ESP_LOGV(TAG, "Skipping LEDC duty update on channel %u while previous duty_start is still set", this->channel_);
return;
}
#endif
ledc_set_duty_with_hpoint(speed_mode, chan_num, duty, hpoint);
ledc_update_duty(speed_mode, chan_num);
this->last_duty_ = duty;
}
}
void LEDCOutput::setup() {
if (!ledc_peripheral_reset_done) {
ESP_LOGV(TAG, "Resetting LEDC peripheral to clear stale state after reboot");
periph_module_reset(PERIPH_LEDC_MODULE);
ledc_peripheral_reset_done = true;
}
auto speed_mode = get_speed_mode(this->channel_);
auto timer_num = static_cast<ledc_timer_t>((this->channel_ % 8) / 2);
auto chan_num = static_cast<ledc_channel_t>(this->channel_ % 8);
@@ -207,12 +252,12 @@ void LEDCOutput::update_frequency(float frequency) {
this->status_clear_error();
// re-apply duty
this->last_duty_ = UINT32_MAX;
this->write_state(this->duty_);
}
uint8_t next_ledc_channel = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace ledc
} // namespace esphome
} // namespace esphome::ledc
#endif

View File

@@ -4,11 +4,11 @@
#include "esphome/core/hal.h"
#include "esphome/core/automation.h"
#include "esphome/components/output/float_output.h"
#include <cstdint>
#ifdef USE_ESP32
namespace esphome {
namespace ledc {
namespace esphome::ledc {
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
extern uint8_t next_ledc_channel;
@@ -39,6 +39,7 @@ class LEDCOutput : public output::FloatOutput, public Component {
float phase_angle_{0.0f};
float frequency_{};
float duty_{0.0f};
uint32_t last_duty_{UINT32_MAX};
bool initialized_ = false;
};
@@ -56,7 +57,6 @@ template<typename... Ts> class SetFrequencyAction : public Action<Ts...> {
LEDCOutput *parent_;
};
} // namespace ledc
} // namespace esphome
} // namespace esphome::ledc
#endif

View File

@@ -26,9 +26,7 @@ void Mutex::unlock() { xSemaphoreGive(this->handle_); }
IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); }
IRAM_ATTR InterruptLock::~InterruptLock() { portENABLE_INTERRUPTS(); }
// LibreTiny doesn't support lwIP core locking, so this is a no-op
LwIPLock::LwIPLock() {}
LwIPLock::~LwIPLock() {}
// LibreTiny LwIPLock is defined inline as a no-op in helpers.h
void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
WiFi.macAddress(mac);

View File

@@ -214,7 +214,14 @@ LightColorValues LightCall::validate_() {
if (this->has_brightness() && this->brightness_ == 0.0f) {
this->state_ = false;
this->set_flag_(FLAG_HAS_STATE);
this->brightness_ = 1.0f;
if (color_mode & ColorCapability::BRIGHTNESS) {
// Reset brightness so the light has nonzero brightness when turned back on.
this->brightness_ = 1.0f;
} else {
// Light doesn't support brightness; clear the flag to avoid a spurious
// "brightness not supported" warning during capability validation.
this->clear_flag_(FLAG_HAS_BRIGHTNESS);
}
}
// Set color brightness to 100% if currently zero and a color is set.
@@ -506,7 +513,7 @@ color_mode_bitmask_t LightCall::get_suitable_color_modes_mask_() {
LightCall &LightCall::set_effect(const char *effect, size_t len) {
if (len == 4 && strncasecmp(effect, "none", 4) == 0) {
this->set_effect(0);
this->set_effect(uint32_t{0});
return *this;
}

View File

@@ -130,6 +130,8 @@ class LightCall {
LightCall &set_effect(optional<std::string> effect);
/// Set the effect of the light by its name.
LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); }
/// Set the effect of the light by its name (const char * overload to resolve ambiguity).
LightCall &set_effect(const char *effect) { return this->set_effect(effect, strlen(effect)); }
/// Set the effect of the light by its name and length (zero-copy from API).
LightCall &set_effect(const char *effect, size_t len);
/// Set the effect of the light by its internal index number (only for internal use).

View File

@@ -1,6 +1,7 @@
#ifdef USE_ESP32
#include "logger.h"
#include "esphome/components/esp32/crash_handler.h"
#include <esp_log.h>
#include <driver/uart.h>
@@ -117,6 +118,9 @@ void Logger::pre_setup() {
esp_log_set_vprintf(esp_idf_log_vprintf_);
ESP_LOGI(TAG, "Log initialized");
#ifdef USE_ESP32_CRASH_HANDLER
esp32::crash_handler_log();
#endif
}
void HOT Logger::write_msg_(const char *msg, uint16_t len) {

View File

@@ -1,6 +1,9 @@
#ifdef USE_RP2040
#include "logger.h"
#include "esphome/core/defines.h"
#ifdef USE_RP2040_CRASH_HANDLER
#include "esphome/components/rp2040/crash_handler.h"
#endif
#include "esphome/core/log.h"
namespace esphome::logger {
@@ -26,7 +29,9 @@ void Logger::pre_setup() {
}
global_logger = this;
ESP_LOGI(TAG, "Log initialized");
#ifdef USE_RP2040_CRASH_HANDLER
rp2040::crash_handler_log();
#endif
}
void HOT Logger::write_msg_(const char *msg, uint16_t len) {

View File

@@ -129,6 +129,10 @@ class MDNSComponent final : public Component {
#endif
#ifdef USE_MDNS_STORE_SERVICES
StaticVector<MDNSService, MDNS_SERVICE_COUNT> services_{};
#endif
#ifdef USE_RP2040
bool was_connected_{false};
bool initialized_{false};
#endif
void compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf);
};

View File

@@ -7,7 +7,12 @@
#include "esphome/core/log.h"
#include "mdns_component.h"
// Arduino-Pico's PolledTimeout.h (pulled in by ESP8266mDNS.h) redefines IRAM_ATTR to empty.
// Save and restore our definition around the include to avoid a redefinition warning.
#pragma push_macro("IRAM_ATTR")
#undef IRAM_ATTR
#include <ESP8266mDNS.h>
#pragma pop_macro("IRAM_ATTR")
namespace esphome::mdns {
@@ -36,12 +41,32 @@ static void register_rp2040(MDNSComponent *, StaticVector<MDNSService, MDNS_SERV
}
void MDNSComponent::setup() {
this->setup_buffers_and_register_(register_rp2040);
// Schedule MDNS.update() via set_interval() instead of overriding loop().
// This removes the component from the per-iteration loop list entirely,
// eliminating virtual dispatch overhead on every main loop cycle.
// See MDNS_UPDATE_INTERVAL_MS comment in mdns_component.h for safety analysis.
this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); });
// RP2040's LEAmDNS library registers a LwipIntf::stateUpCB() callback to restart
// mDNS when the network interface reconnects. However, stateUpCB() is stubbed out
// in arduino-pico's LwipIntfCB.cpp because the original ESP8266 implementation used
// schedule_function() which doesn't exist in arduino-pico, and the callback can't
// safely run directly since netif status callbacks fire from IRQ context
// (PICO_CYW43_ARCH_THREADSAFE_BACKGROUND) while _restart() allocates UDP sockets.
//
// Workaround: defer MDNS.begin() and service registration until the network is
// connected (has an IP), then call notifyAPChange() on subsequent reconnects to
// restart mDNS probing and announcing — all from main loop context so it's
// thread-safe.
this->set_interval(MDNS_UPDATE_INTERVAL_MS, [this]() {
bool connected = network::is_connected();
if (connected && !this->was_connected_) {
if (!this->initialized_) {
this->setup_buffers_and_register_(register_rp2040);
this->initialized_ = true;
} else {
MDNS.notifyAPChange();
}
}
this->was_connected_ = connected;
if (this->initialized_) {
MDNS.update();
}
});
}
void MDNSComponent::on_shutdown() {

View File

@@ -125,13 +125,17 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) {
// Byte 0: modbus address (match all)
if (at == 0)
return true;
uint8_t address = raw[0];
uint8_t function_code = raw[1];
// Byte 1: function code
if (at == 1)
return true;
// Byte 2: Size (with modbus rtu function code 4/3)
// See also https://en.wikipedia.org/wiki/Modbus
if (at == 2)
return true;
uint8_t address = raw[0];
uint8_t function_code = raw[1];
uint8_t data_len = raw[2];
uint8_t data_offset = 3;
@@ -146,10 +150,6 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) {
// chance that this is a complete message ... admittedly there is a small chance is
// isn't but that is quite small given the purpose of the CRC in the first place
// Fewer than 2 bytes can't calc CRC
if (at < 2)
return true;
data_len = at - 2;
data_offset = 1;

View File

@@ -129,7 +129,7 @@ void OnlineImage::update() {
}
ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size);
this->start_time_ = ::time(nullptr);
this->start_time_ = millis();
this->enable_loop();
}
@@ -155,8 +155,8 @@ void OnlineImage::loop() {
// Finalize decoding
this->end_decode();
ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 "s", this->downloader_->get_bytes_read(),
(uint32_t) (::time(nullptr) - this->start_time_));
ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 " ms", this->downloader_->get_bytes_read(),
millis() - this->start_time_);
// Save caching headers
this->etag_ = this->downloader_->get_response_header(ETAG_HEADER_NAME);

View File

@@ -97,7 +97,7 @@ class OnlineImage : public PollingComponent,
*/
std::string last_modified_ = "";
time_t start_time_;
uint32_t start_time_{0};
};
template<typename... Ts> class OnlineImageSetUrlAction : public Action<Ts...> {

View File

@@ -105,6 +105,7 @@ OTAResponseTypes ESP8266OTABackend::begin(size_t image_size) {
this->current_address_ = this->start_address_;
this->image_size_ = image_size;
this->bytes_received_ = 0;
this->buffer_len_ = 0;
this->md5_set_ = false;
@@ -140,6 +141,7 @@ OTAResponseTypes ESP8266OTABackend::write(uint8_t *data, size_t len) {
size_t to_buffer = std::min(len - written, this->buffer_size_ - this->buffer_len_);
memcpy(this->buffer_.get() + this->buffer_len_, data + written, to_buffer);
this->buffer_len_ += to_buffer;
this->bytes_received_ += to_buffer;
written += to_buffer;
// If buffer is full, write to flash
@@ -252,8 +254,8 @@ OTAResponseTypes ESP8266OTABackend::end() {
}
}
// Calculate actual bytes written
size_t actual_size = this->current_address_ - this->start_address_;
// Calculate actual bytes written (exact uploaded size, excluding flash write padding)
size_t actual_size = this->bytes_received_;
// Check if any data was written
if (actual_size == 0) {
@@ -304,6 +306,7 @@ void ESP8266OTABackend::abort() {
this->buffer_.reset();
this->buffer_len_ = 0;
this->image_size_ = 0;
this->bytes_received_ = 0;
esp8266::preferences_prevent_write(false);
}

View File

@@ -48,6 +48,7 @@ class ESP8266OTABackend final {
uint32_t start_address_{0};
uint32_t current_address_{0};
size_t image_size_{0};
size_t bytes_received_{0};
md5::MD5Digest md5_{};
uint8_t expected_md5_[16]; // Fixed-size buffer for 128-bit (16-byte) MD5 digest

View File

@@ -203,7 +203,12 @@ async def to_code(config):
cg.add_build_flag(f"-Wl,--wrap={symbol}")
cg.add_platformio_option("board_build.core", "earlephilhower")
cg.add_platformio_option("board_build.filesystem_size", "1m")
# In testing mode, use all flash for sketch to allow linking grouped component tests.
# Real RP2040 hardware uses 1MB filesystem + 1MB sketch, but CI tests may combine
# many components that exceed the 1MB sketch partition.
cg.add_platformio_option(
"board_build.filesystem_size", "0m" if CORE.testing_mode else "1m"
)
ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
cg.add_define(
@@ -212,6 +217,7 @@ async def to_code(config):
)
cg.add_define("USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT])
cg.add_define("USE_RP2040_CRASH_HANDLER")
def add_pio_file(component: str, key: str, data: str):

View File

@@ -1,8 +1,10 @@
#ifdef USE_RP2040
#include "core.h"
#include "crash_handler.h"
#include "esphome/core/defines.h"
#ifdef USE_RP2040_CRASH_HANDLER
#include "crash_handler.h"
#endif
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
@@ -25,7 +27,9 @@ void arch_restart() {
}
void arch_init() {
#ifdef USE_RP2040_CRASH_HANDLER
rp2040::crash_handler_read_and_clear();
#endif
#if USE_RP2040_WATCHDOG_TIMEOUT > 0
watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false);
#endif

View File

@@ -1,5 +1,8 @@
#ifdef USE_RP2040
#include "esphome/core/defines.h"
#ifdef USE_RP2040_CRASH_HANDLER
#include "crash_handler.h"
#include "esphome/core/log.h"
@@ -13,13 +16,19 @@
static constexpr uint32_t EF_LR = 5;
static constexpr uint32_t EF_PC = 6;
static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF;
// Version encoded in the magic value: upper 16 bits are sentinel (0xDEAD),
// lower 16 bits are the version number. This avoids using a separate scratch
// register for versioning (we only have 8 total). Future firmware reads the
// sentinel to confirm it's crash data, then the version to know the layout.
static constexpr uint32_t CRASH_MAGIC_SENTINEL = 0xDEAD0000;
static constexpr uint32_t CRASH_DATA_VERSION = 1;
static constexpr uint32_t CRASH_MAGIC_V1 = CRASH_MAGIC_SENTINEL | CRASH_DATA_VERSION;
// We only have 8 scratch registers (32 bytes) that survive watchdog reboot.
// Use them for the most important data, then scan the stack for code addresses.
//
// Scratch register layout:
// [0] = magic (CRASH_MAGIC)
// [0] = versioned magic (upper 16 bits = 0xDEAD sentinel, lower 16 bits = version)
// [1] = PC (program counter at fault)
// [2] = LR (link register from exception frame)
// [3] = SP (stack pointer at fault)
@@ -48,18 +57,21 @@ static const char *const TAG = "rp2040.crash";
// Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear().
// The valid field is explicitly cleared in crash_handler_read_and_clear() instead.
static struct {
static struct CrashData {
bool valid;
uint32_t pc;
uint32_t lr;
uint32_t sp;
uint32_t backtrace[MAX_BACKTRACE];
uint8_t backtrace_count;
} __attribute__((section(".noinit"))) s_crash_data;
} s_crash_data __attribute__((section(".noinit")));
bool crash_handler_has_data() { return s_crash_data.valid; }
void crash_handler_read_and_clear() {
s_crash_data.valid = false;
if (watchdog_hw->scratch[0] == CRASH_MAGIC) {
uint32_t magic = watchdog_hw->scratch[0];
if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) {
s_crash_data.valid = true;
s_crash_data.pc = watchdog_hw->scratch[1];
s_crash_data.lr = watchdog_hw->scratch[2];
@@ -135,7 +147,7 @@ static void __attribute__((used, noreturn)) hard_fault_handler_c(uint32_t *frame
// by a stacking error or corrupted SP, frame may be invalid. Write a minimal
// crash marker so we at least know a crash occurred.
if (!is_valid_sram_ptr(frame)) {
watchdog_hw->scratch[0] = CRASH_MAGIC;
watchdog_hw->scratch[0] = CRASH_MAGIC_V1;
watchdog_hw->scratch[1] = 0; // PC unknown
watchdog_hw->scratch[2] = 0; // LR unknown
watchdog_hw->scratch[3] = reinterpret_cast<uintptr_t>(frame); // Record the bad SP for diagnosis
@@ -157,7 +169,7 @@ static void __attribute__((used, noreturn)) hard_fault_handler_c(uint32_t *frame
uint32_t pre_fault_sp = reinterpret_cast<uintptr_t>(post_frame);
// Write key registers
watchdog_hw->scratch[0] = CRASH_MAGIC;
watchdog_hw->scratch[0] = CRASH_MAGIC_V1;
watchdog_hw->scratch[1] = frame[EF_PC];
watchdog_hw->scratch[2] = frame[EF_LR];
watchdog_hw->scratch[3] = pre_fault_sp;
@@ -224,4 +236,5 @@ extern "C" void __attribute__((naked, used)) isr_hardfault() {
: "i"(hard_fault_handler_c));
}
#endif // USE_RP2040_CRASH_HANDLER
#endif // USE_RP2040

View File

@@ -2,7 +2,9 @@
#ifdef USE_RP2040
#include <cstdint>
#include "esphome/core/defines.h"
#ifdef USE_RP2040_CRASH_HANDLER
namespace esphome::rp2040 {
@@ -12,6 +14,10 @@ void crash_handler_read_and_clear();
/// Log crash data if a crash was detected on previous boot.
void crash_handler_log();
/// Returns true if crash data was found this boot.
bool crash_handler_has_data();
} // namespace esphome::rp2040
#endif // USE_RP2040_CRASH_HANDLER
#endif // USE_RP2040

View File

@@ -26,6 +26,10 @@ class BmpDecoder : public ImageDecoder {
int HOT decode(uint8_t *buffer, size_t size) override;
bool is_finished() const override {
if (this->bits_per_pixel_ == 0) {
// header not yet received, so dimensions not yet determined
return false;
}
// BMP is finished when we've decoded all pixel data
return this->paint_index_ >= static_cast<size_t>(this->width_ * this->height_);
}

View File

@@ -41,7 +41,7 @@ SelectCall &SelectCall::with_index(size_t index) {
this->operation_ = SELECT_OP_SET;
if (index >= this->parent_->size()) {
ESP_LOGW(TAG, "'%s' - Index value %zu out of bounds", this->parent_->get_name().c_str(), index);
this->index_ = {}; // Store nullopt for invalid index
this->index_ = nullopt; // Store nullopt for invalid index
} else {
this->index_ = index;
}
@@ -52,7 +52,7 @@ optional<size_t> SelectCall::calculate_target_index_(const char *name) {
const auto &options = this->parent_->traits.get_options();
if (options.empty()) {
ESP_LOGW(TAG, "'%s' - Select has no options", name);
return {};
return nullopt;
}
if (this->operation_ == SELECT_OP_FIRST) {
@@ -67,7 +67,7 @@ optional<size_t> SelectCall::calculate_target_index_(const char *name) {
ESP_LOGD(TAG, "'%s' - Setting", name);
if (!this->index_.has_value()) {
ESP_LOGW(TAG, "'%s' - No option set", name);
return {};
return nullopt;
}
return this->index_;
}
@@ -96,7 +96,7 @@ optional<size_t> SelectCall::calculate_target_index_(const char *name) {
return active_index + 1;
}
return {}; // Can't navigate further without cycling
return nullopt; // Can't navigate further without cycling
}
void SelectCall::perform() {

View File

@@ -14,7 +14,7 @@
#endif
#ifdef USE_LWIP_FAST_SELECT
struct lwip_sock;
#include "esphome/core/lwip_fast_select.h"
#endif
namespace esphome::socket {
@@ -56,6 +56,15 @@ class BSDSocketImpl {
return ::getsockopt(this->fd_, level, optname, optval, optlen);
}
int setsockopt(int level, int optname, const void *optval, socklen_t optlen) {
#if defined(USE_LWIP_FAST_SELECT) && defined(CONFIG_LWIP_TCPIP_CORE_LOCKING)
// Fast path for TCP_NODELAY: directly set the pcb flag under the TCPIP core lock,
// bypassing lwip_setsockopt overhead (socket lookups, hook, switch cascade, refcounting).
if (level == IPPROTO_TCP && optname == TCP_NODELAY && optlen == sizeof(int) && optval != nullptr) {
LwIPLock lock;
if (esphome_lwip_set_nodelay(this->cached_sock_, *reinterpret_cast<const int *>(optval) != 0))
return 0;
}
#endif
return ::setsockopt(this->fd_, level, optname, optval, optlen);
}
int listen(int backlog) { return ::listen(this->fd_, backlog); }

View File

@@ -51,6 +51,8 @@
#define SO_REUSEADDR 0x0004 /* Allow local address reuse */
#define SO_KEEPALIVE 0x0008 /* keep connections alive */
#define SO_BROADCAST 0x0020 /* permit to send and to receive broadcast messages (see IP_SOF_BROADCAST option) */
#define SO_RCVTIMEO 0x1006 /* receive timeout */
#define SO_SNDTIMEO 0x1005 /* send timeout */
#define SOL_SOCKET 0xfff /* options for socket level */

View File

@@ -5,6 +5,7 @@
#include <cerrno>
#include <cstring>
#include <sys/time.h>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -81,7 +82,9 @@ void socket_delay(uint32_t ms) {
s_socket_woke = false;
return;
}
s_socket_woke = false;
// Don't clear s_socket_woke here — if an IRQ fires between the check above
// and the while loop below, the while condition sees it immediately. Clearing
// here would lose that wake and sleep until the timer fires.
s_delay_expired = false;
// Set a one-shot timer to wake us after the timeout.
// add_alarm_in_ms returns >0 on success, 0 if time already passed, <0 on error.
@@ -99,6 +102,7 @@ void socket_delay(uint32_t ms) {
// Cancel timer if we woke early (socket data arrived before timeout)
if (!s_delay_expired)
cancel_alarm(alarm);
s_socket_woke = false; // consume the wake for next call
}
// No IRAM_ATTR equivalent needed: on RP2040, CYW43 async_context runs LWIP
@@ -138,13 +142,46 @@ static const char *const TAG = "socket.lwip";
#define LWIP_LOG(msg, ...)
#endif
// Clear arg, recv, and err callbacks, then abort a connected PCB.
// Only valid for full tcp_pcb (not tcp_pcb_listen).
// Must be called before destroying the object that tcp_arg points to —
// tcp_abort() triggers the err callback synchronously, which would
// otherwise call back into a partially-destroyed object.
// tcp_sent/tcp_poll are not cleared because this implementation
// never registers them.
static void pcb_detach_abort(struct tcp_pcb *pcb) {
tcp_arg(pcb, nullptr);
tcp_recv(pcb, nullptr);
tcp_err(pcb, nullptr);
tcp_abort(pcb);
}
// Clear arg, recv, and err callbacks, then gracefully close a connected PCB.
// Only valid for full tcp_pcb (not tcp_pcb_listen).
// After tcp_close(), the PCB remains alive during the TCP close handshake
// (FIN_WAIT, TIME_WAIT states). Without clearing callbacks first, LWIP
// would call recv/err on a destroyed socket object, corrupting the heap.
// tcp_sent/tcp_poll are not cleared because this implementation
// never registers them.
// Returns ERR_OK on success; on failure the PCB is aborted instead.
static err_t pcb_detach_close(struct tcp_pcb *pcb) {
tcp_arg(pcb, nullptr);
tcp_recv(pcb, nullptr);
tcp_err(pcb, nullptr);
err_t err = tcp_close(pcb);
if (err != ERR_OK) {
tcp_abort(pcb);
}
return err;
}
// ---- LWIPRawCommon methods ----
LWIPRawCommon::~LWIPRawCommon() {
LWIP_LOCK();
if (this->pcb_ != nullptr) {
LWIP_LOG("tcp_abort(%p)", this->pcb_);
tcp_abort(this->pcb_);
pcb_detach_abort(this->pcb_);
this->pcb_ = nullptr;
}
}
@@ -222,15 +259,13 @@ int LWIPRawCommon::close() {
return -1;
}
LWIP_LOG("tcp_close(%p)", this->pcb_);
err_t err = tcp_close(this->pcb_);
err_t err = pcb_detach_close(this->pcb_);
this->pcb_ = nullptr;
if (err != ERR_OK) {
LWIP_LOG(" -> err %d", err);
tcp_abort(this->pcb_);
this->pcb_ = nullptr;
errno = err == ERR_MEM ? ENOMEM : EIO;
return -1;
}
this->pcb_ = nullptr;
return 0;
}
@@ -328,6 +363,18 @@ int LWIPRawCommon::getsockopt(int level, int optname, void *optval, socklen_t *o
*optlen = 4;
return 0;
}
if (level == SOL_SOCKET && optname == SO_RCVTIMEO) {
if (*optlen < sizeof(struct timeval)) {
errno = EINVAL;
return -1;
}
uint32_t ms = this->recv_timeout_cs_ * 10;
auto *tv = reinterpret_cast<struct timeval *>(optval);
tv->tv_sec = ms / 1000;
tv->tv_usec = (ms % 1000) * 1000;
*optlen = sizeof(struct timeval);
return 0;
}
if (level == IPPROTO_TCP && optname == TCP_NODELAY) {
if (*optlen < 4) {
errno = EINVAL;
@@ -357,6 +404,21 @@ int LWIPRawCommon::setsockopt(int level, int optname, const void *optval, sockle
// to prevent warnings
return 0;
}
if (level == SOL_SOCKET && optname == SO_RCVTIMEO) {
if (optlen < sizeof(struct timeval)) {
errno = EINVAL;
return -1;
}
const auto *tv = reinterpret_cast<const struct timeval *>(optval);
uint32_t ms = tv->tv_sec * 1000 + tv->tv_usec / 1000;
uint32_t cs = (ms + 9) / 10; // round up to nearest centisecond
this->recv_timeout_cs_ = cs > 255 ? 255 : static_cast<uint8_t>(cs);
return 0;
}
if (level == SOL_SOCKET && optname == SO_SNDTIMEO) {
// Raw TCP writes are non-blocking (tcp_write), so send timeout is a no-op.
return 0;
}
if (level == IPPROTO_TCP && optname == TCP_NODELAY) {
if (optlen != 4) {
errno = EINVAL;
@@ -487,8 +549,25 @@ err_t LWIPRawImpl::recv_fn(struct pbuf *pb, err_t err) {
return ERR_OK;
}
ssize_t LWIPRawImpl::read(void *buf, size_t len) {
LWIP_LOCK();
void LWIPRawImpl::wait_for_data_() {
// Wait for data without holding LWIP_LOCK so recv_fn() can run on RP2040
// (needs async_context lock).
//
// Loop until data arrives, connection closes, or the full timeout elapses.
// socket_delay() may return early due to other sockets waking the global
// socket_wake() flag, so we re-enter for the remaining time.
uint32_t timeout_ms = this->recv_timeout_cs_ * 10;
uint32_t start = millis();
while (this->waiting_for_data_()) {
uint32_t elapsed = millis() - start;
if (elapsed >= timeout_ms)
break;
socket_delay(timeout_ms - elapsed);
}
}
ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) {
// Caller must hold LWIP_LOCK. Copies available data from rx_buf_ into buf.
if (this->pcb_ == nullptr) {
errno = ECONNRESET;
return -1;
@@ -547,11 +626,26 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) {
return read;
}
ssize_t LWIPRawImpl::read(void *buf, size_t len) {
// See waiting_for_data_() for safety of unlocked reads.
if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) {
this->wait_for_data_();
}
LWIP_LOCK();
return this->read_locked_(buf, len);
}
ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) {
// See waiting_for_data_() for safety of unlocked reads.
if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) {
this->wait_for_data_();
}
LWIP_LOCK(); // Hold for entire scatter-gather operation
ssize_t ret = 0;
for (int i = 0; i < iovcnt; i++) {
ssize_t err = this->read(reinterpret_cast<uint8_t *>(iov[i].iov_base), iov[i].iov_len);
ssize_t err = this->read_locked_(reinterpret_cast<uint8_t *>(iov[i].iov_base), iov[i].iov_len);
if (err == -1) {
if (ret != 0) {
// if we already read some don't return an error
@@ -673,13 +767,10 @@ ssize_t LWIPRawImpl::writev(const struct iovec *iov, int iovcnt) {
LWIPRawListenImpl::~LWIPRawListenImpl() {
LWIP_LOCK();
// Abort any queued PCBs that were never accepted by the main loop.
// Clear the error callback first — tcp_abort triggers it, and we don't
// want s_queued_err_fn writing to slots during destruction.
for (uint8_t i = 0; i < this->accepted_socket_count_; i++) {
auto &entry = this->accepted_pcbs_[i];
if (entry.pcb != nullptr) {
tcp_err(entry.pcb, nullptr);
tcp_abort(entry.pcb);
pcb_detach_abort(entry.pcb);
entry.pcb = nullptr;
}
if (entry.rx_buf != nullptr) {
@@ -691,6 +782,10 @@ LWIPRawListenImpl::~LWIPRawListenImpl() {
// Listen PCBs must use tcp_close(), not tcp_abort().
// tcp_abandon() asserts pcb->state != LISTEN and would access
// fields that don't exist in the smaller tcp_pcb_listen struct.
// Don't use pcb_detach_close() here — tcp_recv()/tcp_err() also access
// fields that only exist in the full tcp_pcb, not tcp_pcb_listen.
// tcp_close() on a listen PCB is synchronous (frees immediately),
// so there are no async callbacks to worry about.
// Close here and null pcb_ so the base destructor skips tcp_abort.
if (this->pcb_ != nullptr) {
tcp_close(this->pcb_);

View File

@@ -57,6 +57,7 @@ class LWIPRawCommon {
// instead use it for determining whether to call lwip_output
bool nodelay_ = false;
sa_family_t family_ = 0;
uint8_t recv_timeout_cs_ = 0; // SO_RCVTIMEO in centiseconds (0 = no timeout, max 2.55s)
};
/// Connected socket implementation for LWIP raw TCP.
@@ -107,11 +108,8 @@ class LWIPRawImpl : public LWIPRawCommon {
errno = ECONNRESET;
return -1;
}
if (blocking) {
// blocking operation not supported
errno = EINVAL;
return -1;
}
// Raw TCP doesn't use a blocking flag directly. Blocking behavior
// is provided by SO_RCVTIMEO which makes read() wait via socket_delay().
return 0;
}
int loop() { return 0; }
@@ -122,6 +120,14 @@ class LWIPRawImpl : public LWIPRawCommon {
static err_t s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err);
protected:
// True when the socket could receive data but none has arrived yet.
// Safe to call without LWIP_LOCK — only null-checks pointers and reads a bool,
// all atomic on ARM/Xtensa. A stale value is harmless: the caller either does
// an unnecessary wait (stale true) or skips it (stale false), and the
// authoritative recheck happens under LWIP_LOCK afterward.
bool waiting_for_data_() const { return this->rx_buf_ == nullptr && !this->rx_closed_ && this->pcb_ != nullptr; }
void wait_for_data_();
ssize_t read_locked_(void *buf, size_t len);
ssize_t internal_write_(const void *buf, size_t len);
int internal_output_();

View File

@@ -10,7 +10,7 @@
#include "headers.h"
#ifdef USE_LWIP_FAST_SELECT
struct lwip_sock;
#include "esphome/core/lwip_fast_select.h"
#endif
namespace esphome::socket {
@@ -52,6 +52,15 @@ class LwIPSocketImpl {
return lwip_getsockopt(this->fd_, level, optname, optval, optlen);
}
int setsockopt(int level, int optname, const void *optval, socklen_t optlen) {
#if defined(USE_LWIP_FAST_SELECT) && defined(CONFIG_LWIP_TCPIP_CORE_LOCKING)
// Fast path for TCP_NODELAY: directly set the pcb flag under the TCPIP core lock,
// bypassing lwip_setsockopt overhead (socket lookups, hook, switch cascade, refcounting).
if (level == IPPROTO_TCP && optname == TCP_NODELAY && optlen == sizeof(int) && optval != nullptr) {
LwIPLock lock;
if (esphome_lwip_set_nodelay(this->cached_sock_, *reinterpret_cast<const int *>(optval) != 0))
return 0;
}
#endif
return lwip_setsockopt(this->fd_, level, optname, optval, optlen);
}
int listen(int backlog) { return lwip_listen(this->fd_, backlog); }

View File

@@ -24,23 +24,23 @@ class TemplateTextSaverBase {
template<uint8_t SZ> class TextSaver : public TemplateTextSaverBase {
public:
bool save(const std::string &value) override {
int diff = value.compare(this->prev_);
if (diff != 0) {
// If string is bigger than the allocation, do not save it.
// We don't need to waste ram setting prev_value either.
int size = value.size();
if (size <= SZ) {
// Make it into a length prefixed thing
unsigned char temp[SZ + 1];
memcpy(temp + 1, value.c_str(), size);
// SZ should be pre checked at the schema level, it can't go past the char range.
temp[0] = ((unsigned char) size);
this->pref_.save(&temp);
this->prev_.assign(value);
return true;
}
if (value == this->prev_) {
return true; // No change, nothing to save
}
return false;
// If string is bigger than the allocation, do not save it.
// We don't need to waste ram setting prev_value either.
int size = value.size();
if (size > SZ) {
return false;
}
// Make it into a length prefixed thing
unsigned char temp[SZ + 1];
memcpy(temp + 1, value.c_str(), size);
// SZ should be pre checked at the schema level, it can't go past the char range.
temp[0] = ((unsigned char) size);
this->pref_.save(&temp);
this->prev_.assign(value);
return true;
}
// Make the preference object. Fill the provided location with the saved data

View File

@@ -26,6 +26,7 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() {
if (!this->supported_modes_.empty()) {
traits.set_supported_modes(this->supported_modes_);
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_OPERATION_MODE);
}
traits.set_supports_current_temperature(true);

View File

@@ -88,16 +88,16 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
struct timeval timev {
.tv_sec = static_cast<time_t>(epoch), .tv_usec = 0,
};
#ifdef USE_ESP8266
// ESP8266 settimeofday() requires tz to be nullptr
int ret = settimeofday(&timev, nullptr);
#else
struct timezone tz = {0, 0};
int ret = settimeofday(&timev, &tz);
if (ret != 0 && errno == EINVAL) {
// Some ESP8266 frameworks abort when timezone parameter is not NULL
// while ESP32 expects it not to be NULL
ret = settimeofday(&timev, nullptr);
}
#endif
if (ret != 0) {
ESP_LOGW(TAG, "setimeofday() failed with code %d", ret);
ESP_LOGW(TAG, "settimeofday() failed with code %d", ret);
}
#endif
auto time = this->now();

View File

@@ -105,15 +105,34 @@ void RP2040UartComponent::setup() {
}
}
// Determine which hardware UART to use. A pin that is not specified
// should not prevent hardware UART selection — one-way UART is valid.
// When both pins are configured, both must be HW-capable and agree on UART number.
// When only one pin is configured (nullptr other), use that pin's HW UART.
// If a pin is configured but not HW-capable (inverted/invalid), fall back to SerialPIO.
int8_t hw_uart = -1;
const bool tx_configured = (this->tx_pin_ != nullptr);
const bool rx_configured = (this->rx_pin_ != nullptr);
if (tx_configured && rx_configured) {
// Both pins configured — both must map to the same hardware UART
if (tx_hw != -1 && rx_hw != -1 && tx_hw == rx_hw) {
hw_uart = tx_hw;
}
} else if (tx_configured) {
hw_uart = tx_hw;
} else if (rx_configured) {
hw_uart = rx_hw;
}
#ifdef USE_LOGGER
if (tx_hw == rx_hw && logger::global_logger->get_uart() == tx_hw) {
ESP_LOGD(TAG, "Using SerialPIO as UART%d is taken by the logger", tx_hw);
tx_hw = -1;
rx_hw = -1;
if (hw_uart != -1 && logger::global_logger->get_uart() == hw_uart) {
ESP_LOGD(TAG, "Using SerialPIO as UART%d is taken by the logger", hw_uart);
hw_uart = -1;
}
#endif
if (tx_hw == -1 || rx_hw == -1 || tx_hw != rx_hw) {
if (hw_uart == -1) {
ESP_LOGV(TAG, "Using SerialPIO");
pin_size_t tx = this->tx_pin_ == nullptr ? NOPIN : this->tx_pin_->get_pin();
pin_size_t rx = this->rx_pin_ == nullptr ? NOPIN : this->rx_pin_->get_pin();
@@ -127,13 +146,15 @@ void RP2040UartComponent::setup() {
} else {
ESP_LOGV(TAG, "Using Hardware Serial");
SerialUART *serial;
if (tx_hw == 0) {
if (hw_uart == 0) {
serial = &Serial1;
} else {
serial = &Serial2;
}
serial->setTX(this->tx_pin_->get_pin());
serial->setRX(this->rx_pin_->get_pin());
if (this->tx_pin_ != nullptr)
serial->setTX(this->tx_pin_->get_pin());
if (this->rx_pin_ != nullptr)
serial->setRX(this->rx_pin_->get_pin());
serial->setFIFOSize(this->rx_buffer_size_);
serial->begin(this->baud_rate_, config);
this->serial_ = serial;

View File

@@ -2181,7 +2181,7 @@ json::SerializationBuffer<> WebServer::update_state_json_generator(WebServer *we
}
json::SerializationBuffer<> WebServer::update_all_json_generator(WebServer *web_server, void *source) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
return web_server->update_json_((update::UpdateEntity *) (source), DETAIL_STATE);
return web_server->update_json_((update::UpdateEntity *) (source), DETAIL_ALL);
}
json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_config) {
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson

View File

@@ -11,6 +11,10 @@ void WebServerBase::add_handler(AsyncWebHandler *handler) {
handler = new internal::AuthMiddlewareHandler(handler, &credentials_);
}
#endif
this->add_handler_without_auth(handler);
}
void WebServerBase::add_handler_without_auth(AsyncWebHandler *handler) {
this->handlers_.push_back(handler);
if (this->server_ != nullptr) {
this->server_->addHandler(handler);

View File

@@ -122,6 +122,14 @@ class WebServerBase {
#endif
void add_handler(AsyncWebHandler *handler);
/**
* WARNING: Registers a handler that bypasses the USE_WEBSERVER_AUTH middleware.
*
* This should only be used for endpoints that are intentionally unauthenticated
* (for example, captive portal or very limited-status endpoints). For normal
* endpoints that should respect web server authentication, use add_handler().
*/
void add_handler_without_auth(AsyncWebHandler *handler);
void set_port(uint16_t port) { port_ = port; }
uint16_t get_port() const { return port_; }

View File

@@ -166,6 +166,7 @@ TTLS_PHASE_2 = {
}
EAP_AUTH_SCHEMA = cv.All(
cv.only_on([Platform.ESP32, Platform.ESP8266]),
cv.Schema(
{
cv.Optional(CONF_IDENTITY): cv.string_strict,
@@ -562,13 +563,6 @@ async def to_code(config):
cg.add_library("ESP8266WiFi", None)
elif CORE.is_rp2040:
cg.add_library("WiFi", None)
# RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart
# mDNS when the network interface reconnects. However, this callback is disabled
# in the arduino-pico framework. As a workaround, we block component setup until
# WiFi is connected via can_proceed(), ensuring mDNS.begin() is called with an
# active connection. This define enables the loop priority sorting infrastructure
# used during the setup blocking phase.
cg.add_define("USE_LOOP_PRIORITY")
if CORE.is_esp32:
if config[CONF_ENABLE_BTM] or config[CONF_ENABLE_RRM]:

View File

@@ -2109,20 +2109,6 @@ void WiFiComponent::retry_connect() {
}
}
#ifdef USE_RP2040
// RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart
// mDNS when the network interface reconnects. However, this callback is disabled
// in the arduino-pico framework. As a workaround, we block component setup until
// WiFi is connected, ensuring mDNS.begin() is called with an active connection.
bool WiFiComponent::can_proceed() {
if (!this->has_sta() || this->state_ == WIFI_COMPONENT_STATE_DISABLED || this->ap_setup_) {
return true;
}
return this->is_connected_();
}
#endif
void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
bool WiFiComponent::is_connected_() const {
return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED &&

View File

@@ -437,10 +437,6 @@ class WiFiComponent : public Component {
void retry_connect();
#ifdef USE_RP2040
bool can_proceed() override;
#endif
void set_reboot_timeout(uint32_t reboot_timeout);
bool is_connected() const { return this->connected_; }

View File

@@ -76,9 +76,7 @@ void Mutex::unlock() { k_mutex_unlock(static_cast<k_mutex *>(this->handle_)); }
IRAM_ATTR InterruptLock::InterruptLock() { state_ = irq_lock(); }
IRAM_ATTR InterruptLock::~InterruptLock() { irq_unlock(state_); }
// Zephyr doesn't support lwIP core locking, so this is a no-op
LwIPLock::LwIPLock() {}
LwIPLock::~LwIPLock() {}
// Zephyr LwIPLock is defined inline as a no-op in helpers.h
uint32_t random_uint32() { return rand(); } // NOLINT(cert-msc30-c, cert-msc50-cpp)
bool random_bytes(uint8_t *data, size_t len) {

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.3.0b1"
__version__ = "2026.3.0b2"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -195,6 +195,7 @@
// ESP32-specific feature flags
#ifdef USE_ESP32
#define USE_ESP32_CRASH_HANDLER
#define USE_MQTT_IDF_ENQUEUE
#define USE_ESPHOME_TASK_LOG_BUFFER
#define USE_OTA_ROLLBACK
@@ -337,6 +338,7 @@
#ifdef USE_RP2040
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0)
#define USE_LOOP_PRIORITY
#define USE_RP2040_CRASH_HANDLER
#define USE_HTTP_REQUEST_RESPONSE
#define USE_I2C
#define USE_LOGGER_USB_CDC

View File

@@ -942,6 +942,28 @@ __attribute__((format(printf, 4, 5))) inline size_t buf_append_printf(char *buf,
}
#endif
/// Safely append a string to buffer without format parsing, returning new position (capped at size).
/// More efficient than buf_append_printf for plain string literals.
/// @param buf Output buffer
/// @param size Total buffer size
/// @param pos Current position in buffer
/// @param str String to append (must not be null)
/// @return New position after appending (capped at size on overflow)
inline size_t buf_append_str(char *buf, size_t size, size_t pos, const char *str) {
if (pos >= size) {
return size;
}
size_t remaining = size - pos - 1; // reserve space for null terminator
size_t len = strlen(str);
if (len > remaining) {
len = remaining;
}
memcpy(buf + pos, str, len);
pos += len;
buf[pos] = '\0';
return pos;
}
/// Concatenate a name with a separator and suffix using an efficient stack-based approach.
/// This avoids multiple heap allocations during string construction.
/// Maximum name length supported is 120 characters for friendly names.
@@ -1779,19 +1801,27 @@ class InterruptLock {
/** Helper class to lock the lwIP TCPIP core when making lwIP API calls from non-TCPIP threads.
*
* This is needed on multi-threaded platforms (ESP32) when CONFIG_LWIP_TCPIP_CORE_LOCKING is enabled.
* It ensures thread-safe access to lwIP APIs.
* This is needed on multi-threaded platforms (ESP32) when CONFIG_LWIP_TCPIP_CORE_LOCKING is enabled,
* and on RP2040 when CYW43 WiFi is active (cyw43_arch_lwip_begin/end).
*
* @note This follows the same pattern as InterruptLock - platform-specific implementations in helpers.cpp
* On platforms without lwIP core locking (ESP8266, LibreTiny, Zephyr),
* this is a no-op defined inline so the compiler can eliminate all call overhead.
*/
class LwIPLock {
public:
LwIPLock();
~LwIPLock();
// Delete copy constructor and copy assignment operator to prevent accidental copying
LwIPLock(const LwIPLock &) = delete;
LwIPLock &operator=(const LwIPLock &) = delete;
#if defined(USE_ESP32) || defined(USE_RP2040)
// Platforms with potential lwIP core locking — out-of-line implementations in helpers.cpp
LwIPLock();
~LwIPLock();
#else
// No lwIP core locking — inline no-ops (empty bodies instead of = default
// to prevent clang-tidy unused-variable warnings at call sites)
LwIPLock() {}
~LwIPLock() {}
#endif
};
/** Helper class to request `loop()` to be called as fast as possible.

View File

@@ -112,6 +112,7 @@
// LwIP headers must come first — they define netconn_callback, struct lwip_sock, etc.
#include <lwip/api.h>
#include <lwip/priv/sockets_priv.h>
#include <lwip/tcp.h>
// FreeRTOS include paths differ: ESP-IDF uses freertos/ prefix, LibreTiny does not
#ifdef USE_ESP32
#include <freertos/FreeRTOS.h>
@@ -216,6 +217,21 @@ void esphome_lwip_hook_socket(struct lwip_sock *sock) {
sock->conn->callback = esphome_socket_event_callback;
}
bool esphome_lwip_set_nodelay(struct lwip_sock *sock, bool enable) {
if (sock == NULL || sock->conn == NULL)
return false;
if (NETCONNTYPE_GROUP(sock->conn->type) != NETCONN_TCP)
return false;
if (sock->conn->pcb.tcp == NULL)
return false;
if (enable) {
tcp_nagle_disable(sock->conn->pcb.tcp);
} else {
tcp_nagle_enable(sock->conn->pcb.tcp);
}
return true;
}
// Wake the main loop from another FreeRTOS task. NOT ISR-safe.
void esphome_lwip_wake_main_loop(void) {
TaskHandle_t task = s_main_loop_task;

View File

@@ -66,6 +66,13 @@ void esphome_lwip_wake_main_loop(void);
/// @param px_higher_priority_task_woken Set to pdTRUE if a context switch is needed.
void esphome_lwip_wake_main_loop_from_isr(int *px_higher_priority_task_woken);
/// Set or clear TCP_NODELAY on a socket's tcp_pcb directly.
/// Must be called with the TCPIP core lock held (LwIPLock in C++).
/// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade,
/// hooks, refcounting) — just a direct pcb->flags bit set/clear.
/// Returns true if successful, false if sock/conn/pcb is NULL or the socket is not TCP.
bool esphome_lwip_set_nodelay(struct lwip_sock *sock, bool enable);
/// Wake the main loop task from any context (ISR, thread, or main loop).
/// ESP32-only: uses xPortInIsrContext() to detect ISR context.
/// LibreTiny lacks IRAM_ATTR support needed for ISR-safe paths.

View File

@@ -4,7 +4,7 @@ dependencies:
esphome/esp-audio-libs:
version: 2.0.3
esphome/micro-opus:
version: 0.3.4
version: 0.3.5
espressif/esp-tflite-micro:
version: 1.3.3~1
espressif/esp32-camera:
@@ -20,7 +20,7 @@ dependencies:
rules:
- if: "target in [esp32h2, esp32p4]"
espressif/esp_hosted:
version: 2.12.0
version: 2.12.1
rules:
- if: "target in [esp32h2, esp32p4]"
zorxx/multipart-parser:

View File

@@ -2,6 +2,7 @@ import contextlib
from datetime import datetime
import json
import logging
import os
import ssl
import tempfile
import time
@@ -109,14 +110,18 @@ def prepare(
CONF_CLIENT_CERTIFICATE_KEY
):
with (
tempfile.NamedTemporaryFile(mode="w+") as cert_file,
tempfile.NamedTemporaryFile(mode="w+") as key_file,
tempfile.NamedTemporaryFile(mode="w+", delete=False) as cert_file,
tempfile.NamedTemporaryFile(mode="w+", delete=False) as key_file,
):
cert_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE))
cert_file.flush()
key_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE_KEY))
key_file.flush()
context.load_cert_chain(cert_file.name, key_file.name)
try:
cert_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE))
key_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE_KEY))
cert_file.close()
key_file.close()
context.load_cert_chain(cert_file.name, key_file.name)
finally:
os.unlink(cert_file.name)
os.unlink(key_file.name)
client.tls_set_context(context)
try:

View File

@@ -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:

View File

@@ -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

View File

@@ -642,7 +642,7 @@ class StringType(TypeInfo):
# For SOURCE_BOTH, check if StringRef is set (sending) or use string (received)
return (
f"if (!this->{self.field_name}_ref_.empty()) {{"
f' out.append("\'").append(this->{self.field_name}_ref_.c_str()).append("\'");'
f' out.append("\'").append(this->{self.field_name}_ref_.c_str(), this->{self.field_name}_ref_.size()).append("\'");'
f"}} else {{"
f' out.append("\'").append(this->{self.field_name}).append("\'");'
f"}}"
@@ -2705,7 +2705,7 @@ namespace esphome::api {
static inline void append_quoted_string(DumpBuffer &out, const StringRef &ref) {
out.append("'");
if (!ref.empty()) {
out.append(ref.c_str());
out.append(ref.c_str(), ref.size());
}
out.append("'");
}

View File

@@ -0,0 +1,11 @@
sensor:
- id: my_sensor
platform: adc
pin: VCC
name: ADC Test sensor
update_interval: "1:01"
unit_of_measurement: "°C"
icon: "mdi:water-percent"
accuracy_decimals: 5
setup_priority: -100
force_update: true

View File

@@ -2,10 +2,10 @@ ethernet:
type: W5500
clk_pin: 19
mosi_pin: 21
miso_pin: 23
miso_pin: 17
cs_pin: 18
interrupt_pin: 36
reset_pin: 22
reset_pin: 12
clock_speed: 10Mhz
manual_ip:
static_ip: 192.168.178.56

View File

@@ -0,0 +1 @@
<<: !include common-ip101.yaml

View File

@@ -0,0 +1 @@
<<: !include common-w5500.yaml

View File

@@ -60,6 +60,12 @@ esphome:
}
}
# Test set_effect with const char* doesn't cause ambiguous overload (issue #14728)
- lambda: |-
auto call = id(test_monochromatic_light).turn_on();
call.set_effect("None");
call.perform();
- light.toggle: test_binary_light
- light.turn_off: test_rgb_light
- light.turn_on:

View File

@@ -0,0 +1,59 @@
#include <gtest/gtest.h>
#include "esphome/components/modbus/modbus.h"
#include "esphome/core/helpers.h"
namespace esphome::modbus {
// Exposes protected methods for testing.
class TestModbus : public Modbus {
public:
bool test_parse_modbus_byte(uint8_t byte) { return this->parse_modbus_byte_(byte); }
void test_clear_rx_buffer() { this->rx_buffer_.clear(); }
void set_waiting(uint8_t addr) { this->waiting_for_response_ = addr; }
};
class MockDevice : public ModbusDevice {
public:
void on_modbus_data(const std::vector<uint8_t> &data) override { this->data_received = true; }
bool data_received{false};
};
TEST(ModbusTest, TwoByteRegressionTest) {
TestModbus modbus;
modbus.set_role(ModbusRole::CLIENT);
// First byte (at=0)
EXPECT_TRUE(modbus.test_parse_modbus_byte(0x01));
// Second byte (at=1)
// This used to reach raw[2] because it skipped the if(at==2) check, causing a
// buffer overflow.
EXPECT_TRUE(modbus.test_parse_modbus_byte(0x03));
}
TEST(ModbusTest, TestValidFrame) {
TestModbus modbus;
modbus.set_role(ModbusRole::CLIENT);
MockDevice device;
device.set_parent(&modbus);
device.set_address(0x01);
modbus.register_device(&device);
modbus.set_waiting(0x01);
// Address 1, Function 3, Length 2, Data 0x1234
uint8_t frame_data[] = {0x01, 0x03, 0x02, 0x12, 0x34};
uint16_t crc = esphome::crc16(frame_data, sizeof(frame_data));
std::vector<uint8_t> frame;
for (uint8_t b : frame_data)
frame.push_back(b);
frame.push_back(crc & 0xFF);
frame.push_back((crc >> 8) & 0xFF);
for (size_t i = 0; i < frame.size(); i++) {
bool result = modbus.test_parse_modbus_byte(frame[i]);
EXPECT_TRUE(result) << "Failed at byte " << i << " (0x" << std::hex << (int) frame[i] << ")";
}
EXPECT_TRUE(device.data_received);
}
} // namespace esphome::modbus

View File

@@ -0,0 +1,6 @@
substitutions:
clk_pin: GPIO2
mosi_pin: GPIO3
miso_pin: GPIO4
<<: !include common.yaml

View File

@@ -23,3 +23,6 @@ uart:
baud_rate: 115200
debug:
debug_prefix: "[UART1] "
- id: uart_rx_only
rx_pin: 17
baud_rate: 1200

View File

@@ -0,0 +1,27 @@
esphome:
name: online-image-bmp
host:
http_request:
display:
online_image:
- url: http://127.0.0.1:HTTP_PORT/foo.bmp
id: myimg
format: BMP
type: RGB
on_download_finished:
logger.log:
format: "download finished. cache hit: %u"
args: [cached]
api:
actions:
- action: fetch_image
then:
- component.update: myimg
logger:
level: DEBUG

View File

@@ -0,0 +1,23 @@
esphome:
name: host-template-text-save-test
host:
api:
batch_delay: 0ms
logger:
preferences:
flash_write_interval: 0s
text:
- platform: template
name: "Test Text Restore"
id: test_text_restore
optimistic: true
min_length: 0
max_length: 10
mode: text
initial_value: "hello"
restore_value: true

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
# black 8x8 RGB BMP, generated with
# from PIL import Image
# from io import BytesIO
# b = BytesIO()
# img = Image.new("RGB", (8, 8))
# img.save(b, format="BMP")
# b.getvalue()
BMP_IMAGE = b"BM\xf6\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x08\x00\x00\x00\x08\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\xc0\x00\x00\x00\xc4\x0e\x00\x00\xc4\x0e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
LEN_BMP_IMAGE = len(BMP_IMAGE)
def handle_http(http_request_future):
async def handler(reader, writer):
try:
async with asyncio.timeout(1.0):
data = await reader.readuntil(b"\r\n")
# ensure our request matches the expectation
expected_request = b"GET /foo.bmp HTTP/1.1\r\n"
assert data[: len(expected_request)] == expected_request
# consume rest of request
async with asyncio.timeout(1.0):
data = await reader.readuntil(b"\r\n\r\n")
http_request_future.set_result(True)
http_response = [
b"HTTP/1.1 200 OK",
b"Content-Length: %d" % LEN_BMP_IMAGE,
b"Content-Type: text/plain",
b"Connection: close",
b"",
b"",
]
writer.write(b"\r\n".join(http_response))
await writer.drain()
writer.write(BMP_IMAGE)
await writer.drain()
except Exception as exc:
if not http_request_future.done():
http_request_future.set_exception(exc)
raise
finally:
writer.close()
return handler
@pytest.mark.asyncio
async def test_online_image_bmp(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Esphome shouldn't block the main loop when a http response is slow"""
loop = asyncio.get_running_loop()
# Track http request
http_request_future = loop.create_future()
download_finished_future = loop.create_future()
downloaded_bytes_future = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if match := re.search(r"Image fully downloaded, (\d+) bytes", line):
downloaded_bytes_future.set_result(int(match.group(1)))
if "download finished" in line:
download_finished_future.set_result(True)
server = await asyncio.start_server(
handle_http(http_request_future), "127.0.0.1", 0
)
http_server_port = server.sockets[0].getsockname()[1]
config = yaml_config.replace("HTTP_PORT", str(http_server_port))
# Run with log monitoring
async with (
server,
run_compiled(config, line_callback=check_output),
api_client_connected() as client,
):
# Verify device info
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "online-image-bmp"
# List services to find our test service
_, services = await client.list_entities_services()
# Find test service
request_service = next((s for s in services if s.name == "fetch_image"), None)
assert request_service is not None, "fetch_image service not found"
await client.execute_service(request_service, {})
async with asyncio.timeout(0.1):
await http_request_future
async with asyncio.timeout(0.5):
numbytes = await downloaded_bytes_future
assert numbytes == LEN_BMP_IMAGE
await download_finished_future

View File

@@ -0,0 +1,131 @@
"""Integration test for template text restore_value persistence.
Tests that:
1. A template text with restore_value saves its value to preferences
2. The saved value persists across restarts (binary re-run)
3. Setting the same value again does not produce a spurious "too long" warning
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import socket
from typing import Any
from aioesphomeapi import TextInfo, TextState
import pytest
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
from .state_utils import InitialStateHelper, require_entity
from .types import CompileFunction, ConfigWriter
@pytest.mark.asyncio
async def test_template_text_save(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
) -> None:
"""Test template text save/restore persistence and duplicate-save behavior."""
port, port_socket = reserved_tcp_port
# Clean up any stale preference file from previous runs
prefs_file = (
Path.home() / ".esphome" / "prefs" / "host-template-text-save-test.prefs"
)
if prefs_file.exists():
prefs_file.unlink()
# Write and compile once
config_path = await write_yaml_config(yaml_config)
binary_path = await compile_esphome(config_path)
# Release the reserved port so the binary can bind to it
port_socket.close()
# --- First run: set a value and verify no spurious warnings ---
warning_lines: list[str] = []
def capture_warnings(line: str) -> None:
if "too long to save" in line.lower():
warning_lines.append(line)
async with (
run_binary_and_wait_for_port(
binary_path, "127.0.0.1", port, line_callback=capture_warnings
),
wait_and_connect_api_client(port=port) as client,
):
device_info = await client.device_info()
assert device_info.name == "host-template-text-save-test"
entities, _ = await client.list_entities_services()
text_entity = require_entity(
entities, "test_text_restore", TextInfo, "Test Text Restore"
)
# Set up state tracking
loop = asyncio.get_running_loop()
state_futures: dict[int, asyncio.Future[Any]] = {}
def on_state(state: Any) -> None:
if state.key in state_futures and not state_futures[state.key].done():
state_futures[state.key].set_result(state)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
# Verify initial value from config
initial = initial_state_helper.initial_states[text_entity.key]
assert isinstance(initial, TextState)
assert initial.state == "hello"
async def wait_for_state(key: int, timeout: float = 2.0) -> Any:
state_futures[key] = loop.create_future()
try:
return await asyncio.wait_for(state_futures[key], timeout)
finally:
state_futures.pop(key, None)
# Set a new value that fits within max_length
client.text_command(key=text_entity.key, state="world")
state = await wait_for_state(text_entity.key)
assert state.state == "world"
# Set the same value again - should NOT produce "too long" warning
client.text_command(key=text_entity.key, state="world")
# Give time for the warning to appear (if any)
await asyncio.sleep(0.5)
# No warnings should have appeared
assert warning_lines == [], (
f"Unexpected 'too long to save' warning(s): {warning_lines}"
)
# --- Second run: verify the value was restored from preferences ---
async with (
run_binary_and_wait_for_port(binary_path, "127.0.0.1", port),
wait_and_connect_api_client(port=port) as client,
):
entities, _ = await client.list_entities_services()
text_entity = require_entity(
entities, "test_text_restore", TextInfo, "Test Text Restore"
)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(lambda s: None))
await initial_state_helper.wait_for_initial_states()
# The value should be "world" - restored from preferences
restored = initial_state_helper.initial_states[text_entity.key]
assert isinstance(restored, TextState)
assert restored.state == "world", (
f"Expected restored value 'world', got '{restored.state}'"
)
# Clean up preference file
if prefs_file.exists():
prefs_file.unlink()

View File

@@ -102,7 +102,11 @@ async def test_water_heater_template(
f"Expected target temp 60.0, got {initial_state.target_temperature}"
)
# Verify supported features: away mode and on/off (fixture has away + is_on lambdas)
# Verify supported features: operation mode, away mode, and on/off
assert (
test_water_heater.supported_features
& WaterHeaterFeature.SUPPORTS_OPERATION_MODE
) != 0, "Expected SUPPORTS_OPERATION_MODE in supported_features"
assert (
test_water_heater.supported_features & WaterHeaterFeature.SUPPORTS_AWAY_MODE
) != 0, "Expected SUPPORTS_AWAY_MODE in supported_features"

View File

@@ -0,0 +1,15 @@
esphome:
name: componenttestrp2040pico2ard
friendly_name: $component_name
rp2040:
board: rpipico2
logger:
level: VERY_VERBOSE
packages:
component_under_test: !include
file: $component_test_file
vars:
component_test_file: $component_test_file

View File

@@ -0,0 +1,12 @@
# Common SPI configuration for RP2040 Pico 2 (RP2350) Arduino tests
substitutions:
clk_pin: GPIO18
mosi_pin: GPIO19
miso_pin: GPIO16
spi:
- id: spi_bus
clk_pin: ${clk_pin}
mosi_pin: ${mosi_pin}
miso_pin: ${miso_pin}

View File

@@ -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,), {})