diff --git a/Doxyfile b/Doxyfile index 9f5cd0a2ce3..4ec3a24c9fe 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.3.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 diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2040.cpp index 8496e0f41e4..a79707e2347 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2040.cpp @@ -8,6 +8,13 @@ #endif // CYW43_USES_VSYS_PIN #include +// 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 { diff --git a/esphome/components/addressable_light/addressable_light_display.h b/esphome/components/addressable_light/addressable_light_display.h index 53f8604b7de..d9b8680547f 100644 --- a/esphome/components/addressable_light/addressable_light_display.h +++ b/esphome/components/addressable_light/addressable_light_display.h @@ -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; diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 3356511684f..68f698d1902 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -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; } diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 98de24501ea..5e07ad43a93 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -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 diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 3e6ecf9dc30..f945253c89d 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -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; } diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 740bf2e47fd..5a53f0281fa 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -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("'"); } diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 200d0938bd5..0e71ad8fcbf 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio from datetime import datetime +import importlib import logging from typing import TYPE_CHECKING, Any import warnings @@ -18,6 +19,7 @@ import contextlib from esphome.const import CONF_KEY, CONF_PORT, __version__ from esphome.core import CORE +from esphome.platformio_api import process_stacktrace from . import CONF_ENCRYPTION @@ -55,9 +57,19 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: addresses=addresses, # Pass all addresses for automatic retry ) dashboard = CORE.dashboard + backtrace_state = False + + # Try platform-specific stacktrace handler first, fall back to generic + platform_process_stacktrace = None + try: + module = importlib.import_module("esphome.components." + CORE.target_platform) + platform_process_stacktrace = getattr(module, "process_stacktrace") + except (AttributeError, ImportError): + pass def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" + nonlocal backtrace_state time_ = datetime.now() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") @@ -67,6 +79,15 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: ) for parsed_msg in parse_log_message(text, timestamp): print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) + for raw_line in text.splitlines(): + if platform_process_stacktrace: + backtrace_state = platform_process_stacktrace( + config, raw_line, backtrace_state + ) + else: + backtrace_state = process_stacktrace( + config, raw_line, backtrace_state=backtrace_state + ) stop = await async_run(cli, on_log, name=name) try: diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index d95fcf66d7b..b28c2ed3d8c 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -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") diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 5af6ab29a2e..183f16c5f84 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -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(); diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index e4f4bb36eba..3da6b800c6f 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -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 buffer); - const char *get_wakeup_cause_(std::span buffer); + const char *get_wakeup_cause_(std::span buffer); uint32_t get_free_heap_(); size_t get_device_info_(std::span buffer, size_t pos); void update_platform_(); diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 6898621dd05..c9df4fdf210 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -98,7 +98,7 @@ static const char *const WAKEUP_CAUSES[] = { "BT", }; -const char *DebugComponent::get_wakeup_cause_(std::span buffer) { +const char *DebugComponent::get_wakeup_cause_(std::span 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 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(reason_buffer)); - const char *wakeup_cause = get_wakeup_cause_(std::span(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(reset_buffer)); + const char *wakeup_cause = get_wakeup_cause_(std::span(wakeup_buffer)); uint8_t mac[6]; get_mac_address_raw(mac); diff --git a/esphome/components/debug/debug_esp8266.cpp b/esphome/components/debug/debug_esp8266.cpp index 4df4aaa8513..0519ab72fe9 100644 --- a/esphome/components/debug/debug_esp8266.cpp +++ b/esphome/components/debug/debug_esp8266.cpp @@ -91,7 +91,7 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { // ESP8266 doesn't have detailed wakeup cause like ESP32 return ""; } diff --git a/esphome/components/debug/debug_host.cpp b/esphome/components/debug/debug_host.cpp index 2fa88f0909c..0dfab86e4c4 100644 --- a/esphome/components/debug/debug_host.cpp +++ b/esphome/components/debug/debug_host.cpp @@ -7,7 +7,7 @@ namespace debug { const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } -const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } uint32_t DebugComponent::get_free_heap_() { return INT_MAX; } diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 39269d6f2f2..1d458c602a6 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -12,7 +12,7 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } uint32_t DebugComponent::get_free_heap_() { return lt_heap_get_free(); } diff --git a/esphome/components/debug/debug_rp2040.cpp b/esphome/components/debug/debug_rp2040.cpp index c9d41942dbc..73f08492c86 100644 --- a/esphome/components/debug/debug_rp2040.cpp +++ b/esphome/components/debug/debug_rp2040.cpp @@ -1,23 +1,81 @@ #include "debug_component.h" #ifdef USE_RP2040 +#include "esphome/core/defines.h" #include "esphome/core/log.h" #include +#include +#if defined(PICO_RP2350) +#include +#else +#include +#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 buffer) { return ""; } +const char *DebugComponent::get_reset_reason_(std::span 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 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 buffer) { return ""; } + +uint32_t DebugComponent::get_free_heap_() { return ::rp2040.getFreeHeap(); } size_t DebugComponent::get_device_info_(std::span 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); diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index bd6432e9499..bf87b7ae3dc 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -53,7 +53,7 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { // Zephyr doesn't have detailed wakeup cause like ESP32 return ""; } diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 52e70501dcf..475de6aa3e4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1442,6 +1442,11 @@ async def to_code(config): cg.add_build_flag("-DUSE_ESP32") cg.add_define("USE_NATIVE_64BIT_TIME") cg.add_build_flag("-Wl,-z,noexecstack") + # Arduino already wraps esp_panic_handler for its own backtrace handler, + # so only add our wrap when using ESP-IDF framework to avoid linker conflicts. + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: + cg.add_build_flag("-Wl,--wrap=esp_panic_handler") + cg.add_define("USE_ESP32_CRASH_HANDLER") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 46c000562e1..cba25bca2b2 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "esphome/core/defines.h" +#include "crash_handler.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "preferences.h" @@ -36,6 +37,11 @@ void arch_restart() { } void arch_init() { +#ifdef USE_ESP32_CRASH_HANDLER + // Read crash data from previous boot before anything else + esp32::crash_handler_read_and_clear(); +#endif + // Enable the task watchdog only on the loop task (from which we're currently running) esp_task_wdt_add(nullptr); diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp new file mode 100644 index 00000000000..ecf30d78781 --- /dev/null +++ b/esphome/components/esp32/crash_handler.cpp @@ -0,0 +1,355 @@ +#ifdef USE_ESP32 + +#include "esphome/core/defines.h" +#ifdef USE_ESP32_CRASH_HANDLER + +#include "crash_handler.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include + +#if CONFIG_IDF_TARGET_ARCH_XTENSA +#include +#include +#include +#elif CONFIG_IDF_TARGET_ARCH_RISCV +#include +#endif + +static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF; +static constexpr size_t MAX_BACKTRACE = 16; + +// Check if an address looks like code (flash-mapped or IRAM). +// Must be safe to call from panic context (no flash access needed). +static inline bool IRAM_ATTR is_code_addr(uint32_t addr) { + return (addr >= SOC_IROM_LOW && addr < SOC_IROM_HIGH) || (addr >= SOC_IRAM_LOW && addr < SOC_IRAM_HIGH); +} + +#if CONFIG_IDF_TARGET_ARCH_RISCV +// Check if a code address is a real return address by verifying the preceding +// instruction is a JAL or JALR with rd=ra (x1). Called at log time (not during +// panic) so flash cache is available and both IRAM and IROM are safely readable. +static inline bool is_return_addr(uint32_t addr) { + if (!is_code_addr(addr) || addr < 4) + return false; + // A return address on the stack points to the instruction after a call. + // Check for 4-byte JAL/JALR call instruction before this address. + // Use memcpy for alignment safety — RISC-V C extension means code addresses + // are only 2-byte aligned, so addr-4 may not be 4-byte aligned. + uint32_t inst; + memcpy(&inst, (const void *) (addr - 4), sizeof(inst)); + // RISC-V instruction encoding: bits [6:0] = opcode, bits [11:7] = rd + uint32_t opcode = inst & 0x7f; // Extract 7-bit opcode + uint32_t rd = inst & 0xf80; // Extract rd field (bits 11:7) + // Match JAL (0x6f) or JALR (0x67) with rd=ra (x1, encoded as 0x80 = 1<<7) + if ((opcode == 0x6f || opcode == 0x67) && rd == 0x80) + return true; + // Check for 2-byte compressed c.jalr before this address (C extension). + // c.jalr saves to ra implicitly: funct4=1001, rs1!=0, rs2=0, op=10 + if (addr >= 2) { + uint16_t c_inst = *(uint16_t *) (addr - 2); + if ((c_inst & 0xf07f) == 0x9002 && (c_inst & 0x0f80) != 0) + return true; + } + return false; +} +#endif + +// Raw crash data written by the panic handler wrapper. +// Lives in .noinit so it survives software reset but contains garbage after power cycle. +// Validated by magic marker. Static linkage since it's only used within this file. +// Version field is first so future firmware can always identify the struct layout. +// Magic is second to validate the data. Remaining fields can change between versions. +// Version is uint32_t because it would be padded to 4 bytes anyway before the next +// uint32_t field, so we use the full width rather than wasting 3 bytes of padding. +static constexpr uint32_t CRASH_DATA_VERSION = 1; +struct RawCrashData { + uint32_t version; + uint32_t magic; + uint32_t pc; + uint8_t backtrace_count; + uint8_t reg_frame_count; // Number of entries from registers (not stack-scanned) + uint8_t exception; // panic_exception_t enum (FAULT/ABORT/IWDT/TWDT/DEBUG) + uint8_t pseudo_excause; // Whether cause is a pseudo exception (Xtensa SoC-level panic) + uint32_t backtrace[MAX_BACKTRACE]; + uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) +}; +static RawCrashData __attribute__((section(".noinit"))) +s_raw_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// Whether crash data was found and validated this boot. +static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +namespace esphome::esp32 { + +static const char *const TAG = "esp32.crash"; + +void crash_handler_read_and_clear() { + if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { + s_crash_data_valid = true; + // Clamp counts to prevent out-of-bounds reads from corrupt .noinit data + if (s_raw_crash_data.backtrace_count > MAX_BACKTRACE) + s_raw_crash_data.backtrace_count = MAX_BACKTRACE; + if (s_raw_crash_data.reg_frame_count > s_raw_crash_data.backtrace_count) + s_raw_crash_data.reg_frame_count = s_raw_crash_data.backtrace_count; + if (s_raw_crash_data.exception > 4) // panic_exception_t max value + s_raw_crash_data.exception = 4; // Default to PANIC_EXCEPTION_FAULT + if (s_raw_crash_data.pseudo_excause > 1) + s_raw_crash_data.pseudo_excause = 0; + } + // Clear magic regardless so we don't re-report on next normal reboot + s_raw_crash_data.magic = 0; +} + +bool crash_handler_has_data() { return s_crash_data_valid; } + +// Look up the exception cause as a human-readable string. +// Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays +// not exposed via any public API. +static const char *get_exception_reason() { +#if CONFIG_IDF_TARGET_ARCH_XTENSA + if (s_raw_crash_data.pseudo_excause) { + // SoC-level panic: watchdog, cache error, etc. + // Keep in sync with ESP-IDF's PANIC_RSN_* defines + static const char *const PSEUDO_REASON[] = { + "Unknown reason", // 0 + "Unhandled debug exception", // 1 + "Double exception", // 2 + "Unhandled kernel exception", // 3 + "Coprocessor exception", // 4 + "Interrupt wdt timeout on CPU0", // 5 + "Interrupt wdt timeout on CPU1", // 6 + "Cache error", // 7 + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(PSEUDO_REASON) / sizeof(PSEUDO_REASON[0])) + return PSEUDO_REASON[cause]; + return PSEUDO_REASON[0]; + } + // Real Xtensa exception + static const char *const REASON[] = { + "IllegalInstruction", + "Syscall", + "InstructionFetchError", + "LoadStoreError", + "Level1Interrupt", + "Alloca", + "IntegerDivideByZero", + "PCValue", + "Privileged", + "LoadStoreAlignment", + nullptr, + nullptr, + "InstrPDAddrError", + "LoadStorePIFDataError", + "InstrPIFAddrError", + "LoadStorePIFAddrError", + "InstTLBMiss", + "InstTLBMultiHit", + "InstFetchPrivilege", + nullptr, + "InstrFetchProhibited", + nullptr, + nullptr, + nullptr, + "LoadStoreTLBMiss", + "LoadStoreTLBMultihit", + "LoadStorePrivilege", + nullptr, + "LoadProhibited", + "StoreProhibited", + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) + return REASON[cause]; +#elif CONFIG_IDF_TARGET_ARCH_RISCV + // For SoC-level panics (watchdog, cache error), mcause holds IDF-internal + // interrupt numbers, not standard RISC-V cause codes. The exception type + // field already identifies these, so just return null to use the type name. + if (s_raw_crash_data.pseudo_excause) + return nullptr; + static const char *const REASON[] = { + "Instruction address misaligned", + "Instruction access fault", + "Illegal instruction", + "Breakpoint", + "Load address misaligned", + "Load access fault", + "Store address misaligned", + "Store access fault", + "Environment call from U-mode", + "Environment call from S-mode", + nullptr, + "Environment call from M-mode", + "Instruction page fault", + "Load page fault", + nullptr, + "Store page fault", + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) + return REASON[cause]; +#endif + return "Unknown"; +} + +// Exception type names matching panic_exception_t enum +static const char *get_exception_type() { + static const char *const TYPES[] = { + "Debug exception", // PANIC_EXCEPTION_DEBUG + "Interrupt wdt", // PANIC_EXCEPTION_IWDT + "Task wdt", // PANIC_EXCEPTION_TWDT + "Abort", // PANIC_EXCEPTION_ABORT + "Fault", // PANIC_EXCEPTION_FAULT + }; + uint8_t exc = s_raw_crash_data.exception; + if (exc < sizeof(TYPES) / sizeof(TYPES[0])) + return TYPES[exc]; + return "Unknown"; +} + +// Intentionally uses separate ESP_LOGE calls per line instead of combining into +// one multi-line log message. This ensures each address appears as its own line +// on the serial console, making it possible to see partial output if the device +// crashes again during boot, and allowing the CLI's process_stacktrace to match +// and decode each address individually. +void crash_handler_log() { + if (!s_crash_data_valid) + return; + + ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); + const char *reason = get_exception_reason(); + if (reason != nullptr) { + ESP_LOGE(TAG, " Reason: %s - %s", get_exception_type(), reason); + } else { + ESP_LOGE(TAG, " Reason: %s", get_exception_type()); + } + ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc); + uint8_t bt_num = 0; + for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count; i++) { + uint32_t addr = s_raw_crash_data.backtrace[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + // Register-sourced entries (MEPC/RA) are trusted; only filter stack-scanned ones. + if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) + continue; +#endif +#if CONFIG_IDF_TARGET_ARCH_RISCV + const char *source = (i < s_raw_crash_data.reg_frame_count) ? "backtrace" : "stack scan"; +#else + const char *source = "backtrace"; +#endif + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (%s)", bt_num++, addr, source); + } + // Build addr2line hint with all captured addresses for easy copy-paste + char hint[256]; + int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); + for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count && pos < (int) sizeof(hint) - 12; i++) { + uint32_t addr = s_raw_crash_data.backtrace[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) + continue; +#endif + pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, addr); + } + ESP_LOGE(TAG, "%s", hint); +} + +} // namespace esphome::esp32 + +// --- Panic handler wrapper --- +// Intercepts esp_panic_handler() via --wrap linker flag to capture crash data +// into NOINIT memory before the normal panic handler runs. +// +extern "C" { +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +// Names are mandated by the --wrap linker mechanism +extern void __real_esp_panic_handler(panic_info_t *info); + +void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { + // Save the faulting PC and exception info + s_raw_crash_data.pc = (uint32_t) info->addr; + s_raw_crash_data.backtrace_count = 0; + s_raw_crash_data.reg_frame_count = 0; + s_raw_crash_data.exception = (uint8_t) info->exception; + s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0; + +#if CONFIG_IDF_TARGET_ARCH_XTENSA + // Xtensa: walk the backtrace using the public API + if (info->frame != nullptr) { + auto *xt_frame = (XtExcFrame *) info->frame; + s_raw_crash_data.cause = xt_frame->exccause; + esp_backtrace_frame_t bt_frame = { + .pc = (uint32_t) xt_frame->pc, + .sp = (uint32_t) xt_frame->a1, + .next_pc = (uint32_t) xt_frame->a0, + .exc_frame = xt_frame, + }; + + uint8_t count = 0; + // First frame PC + uint32_t first_pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(first_pc)) { + s_raw_crash_data.backtrace[count++] = first_pc; + } + // Walk remaining frames + while (count < MAX_BACKTRACE && bt_frame.next_pc != 0) { + if (!esp_backtrace_get_next_frame(&bt_frame)) { + break; + } + uint32_t pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(pc)) { + s_raw_crash_data.backtrace[count++] = pc; + } + } + s_raw_crash_data.backtrace_count = count; + } + +#elif CONFIG_IDF_TARGET_ARCH_RISCV + // RISC-V: capture MEPC + RA, then scan stack for code addresses + if (info->frame != nullptr) { + auto *rv_frame = (RvExcFrame *) info->frame; + s_raw_crash_data.cause = rv_frame->mcause; + uint8_t count = 0; + + // Save MEPC (fault PC) and RA (return address) + if (is_code_addr(rv_frame->mepc)) { + s_raw_crash_data.backtrace[count++] = rv_frame->mepc; + } + if (is_code_addr(rv_frame->ra) && rv_frame->ra != rv_frame->mepc) { + s_raw_crash_data.backtrace[count++] = rv_frame->ra; + } + + // Track how many entries came from registers (MEPC/RA) so we can + // skip return-address validation for them at log time. + s_raw_crash_data.reg_frame_count = count; + + // Scan stack for code addresses — captures broadly during panic, + // filtered by is_return_addr() at log time when flash is accessible. + auto *scan_start = (uint32_t *) rv_frame->sp; + for (uint32_t i = 0; i < 64 && count < MAX_BACKTRACE; i++) { + uint32_t val = scan_start[i]; + if (is_code_addr(val) && val != rv_frame->mepc && val != rv_frame->ra) { + s_raw_crash_data.backtrace[count++] = val; + } + } + s_raw_crash_data.backtrace_count = count; + } +#endif + + // Write version and magic last — ensures all data is written before we mark it valid + s_raw_crash_data.version = CRASH_DATA_VERSION; + s_raw_crash_data.magic = CRASH_MAGIC; + + // Call the real panic handler (prints to UART, does core dump, reboots, etc.) + __real_esp_panic_handler(info); +} + +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +} // extern "C" + +#endif // USE_ESP32_CRASH_HANDLER +#endif // USE_ESP32 diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h new file mode 100644 index 00000000000..97a4d4e1162 --- /dev/null +++ b/esphome/components/esp32/crash_handler.h @@ -0,0 +1,18 @@ +#pragma once + +#ifdef USE_ESP32_CRASH_HANDLER + +namespace esphome::esp32 { + +/// Read crash data from NOINIT memory and clear the magic marker. +void crash_handler_read_and_clear(); + +/// Log crash data if a crash was detected on previous boot. +void crash_handler_log(); + +/// Returns true if crash data was found this boot. +bool crash_handler_has_data(); + +} // namespace esphome::esp32 + +#endif // USE_ESP32_CRASH_HANDLER diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 2f17334c77c..9d6e079d926 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -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: { diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index af4f1b30290..4e0b22cc299 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -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); diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 6d49053d6d5..a51ae2cd666 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -105,7 +105,7 @@ async def to_code(config): if framework_ver >= cv.Version(5, 5, 0): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.4.0") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.4") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.0") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.1") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/components/esp8266/helpers.cpp b/esphome/components/esp8266/helpers.cpp index 036594fa178..4a64ae181e3 100644 --- a/esphome/components/esp8266/helpers.cpp +++ b/esphome/components/esp8266/helpers.cpp @@ -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); diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index a1cdf59d2b7..d8dbe2dee2d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -18,6 +18,7 @@ #include #include +#include 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; } diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index d6b0d40cd95..e0788e11498 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -21,22 +21,6 @@ namespace esphome::ethernet { -#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) -// work around IDF compile issue on P4 https://github.com/espressif/esp-idf/pull/15637 -#ifdef USE_ESP32_VARIANT_ESP32P4 -#undef ETH_ESP32_EMAC_DEFAULT_CONFIG -#define ETH_ESP32_EMAC_DEFAULT_CONFIG() \ - { \ - .smi_gpio = {.mdc_num = 31, .mdio_num = 52}, .interface = EMAC_DATA_INTERFACE_RMII, \ - .clock_config = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) 50}}, \ - .dma_burst_len = ETH_DMA_BURST_LEN_32, .intr_priority = 0, \ - .emac_dataif_gpio = \ - {.rmii = {.tx_en_num = 49, .txd0_num = 34, .txd1_num = 35, .crs_dv_num = 28, .rxd0_num = 29, .rxd1_num = 30}}, \ - .clock_config_out_in = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) -1}}, \ - } -#endif -#endif - static const char *const TAG = "ethernet"; // PHY register size for hex logging @@ -162,7 +146,7 @@ void EthernetComponent::setup() { phy_config.phy_addr = this->phy_addr_; phy_config.reset_gpio_num = this->power_pin_; - eth_esp32_emac_config_t esp32_emac_config = ETH_ESP32_EMAC_DEFAULT_CONFIG(); + eth_esp32_emac_config_t esp32_emac_config = eth_esp32_emac_default_config(); #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) esp32_emac_config.smi_gpio.mdc_num = this->mdc_pin_; esp32_emac_config.smi_gpio.mdio_num = this->mdio_pin_; diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index d9f05be9de0..f7a0996fb74 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -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 diff --git a/esphome/components/ethernet/ethernet_helpers.c b/esphome/components/ethernet/ethernet_helpers.c new file mode 100644 index 00000000000..963db3ff1c4 --- /dev/null +++ b/esphome/components/ethernet/ethernet_helpers.c @@ -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 diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index de3f2be6740..1684f479ba3 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -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"] diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 5120eb4c007..47a06abe9ec 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -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 diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index 763de851da3..592fc7bd0c1 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -5,6 +5,10 @@ #include #include +#include +#if !defined(SOC_LEDC_SUPPORT_FADE_STOP) +#include +#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(CLOCK_FREQUENCY) / static_cast(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(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(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((this->channel_ % 8) / 2); auto chan_num = static_cast(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 diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index b24e3cfdb23..bf5cdb93055 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -4,11 +4,11 @@ #include "esphome/core/hal.h" #include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" +#include #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 class SetFrequencyAction : public Action { LEDCOutput *parent_; }; -} // namespace ledc -} // namespace esphome +} // namespace esphome::ledc #endif diff --git a/esphome/components/libretiny/helpers.cpp b/esphome/components/libretiny/helpers.cpp index 37ae0fb455a..21913e4a16d 100644 --- a/esphome/components/libretiny/helpers.cpp +++ b/esphome/components/libretiny/helpers.cpp @@ -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); diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 14cd0e92f69..0b2d391fd6e 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -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; } diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 0926ab6108e..0eb1785239c 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -130,6 +130,8 @@ class LightCall { LightCall &set_effect(optional 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). diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index d6ad77ff4fa..f5bf7822899 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "logger.h" +#include "esphome/components/esp32/crash_handler.h" #include #include @@ -117,6 +118,9 @@ void Logger::pre_setup() { esp_log_set_vprintf(esp_idf_log_vprintf_); ESP_LOGI(TAG, "Log initialized"); +#ifdef USE_ESP32_CRASH_HANDLER + esp32::crash_handler_log(); +#endif } void HOT Logger::write_msg_(const char *msg, uint16_t len) { diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index f76b823a8f7..b7225c2a258 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -1,6 +1,9 @@ #ifdef USE_RP2040 #include "logger.h" +#include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER #include "esphome/components/rp2040/crash_handler.h" +#endif #include "esphome/core/log.h" namespace esphome::logger { @@ -26,7 +29,9 @@ void Logger::pre_setup() { } global_logger = this; ESP_LOGI(TAG, "Log initialized"); +#ifdef USE_RP2040_CRASH_HANDLER rp2040::crash_handler_log(); +#endif } void HOT Logger::write_msg_(const char *msg, uint16_t len) { diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 13c8ccf2884..47cad4bf71b 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -129,6 +129,10 @@ class MDNSComponent final : public Component { #endif #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; +#endif +#ifdef USE_RP2040 + bool was_connected_{false}; + bool initialized_{false}; #endif void compile_records_(StaticVector &services, char *mac_address_buf); }; diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 05d991c1fad..88f707afd37 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -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 +#pragma pop_macro("IRAM_ATTR") namespace esphome::mdns { @@ -36,12 +41,32 @@ static void register_rp2040(MDNSComponent *, StaticVectorsetup_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() { diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 82672217c56..7a61868e6e9 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -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; diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index da866599c9d..22bf6a3056f 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -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); diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index c7c80c7c667..12c25645260 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -97,7 +97,7 @@ class OnlineImage : public PollingComponent, */ std::string last_modified_ = ""; - time_t start_time_; + uint32_t start_time_{0}; }; template class OnlineImageSetUrlAction : public Action { diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 1f9a77e4261..93e6249fb3d 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -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); } diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index 6213289accb..b364e216a36 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -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 diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index b15811241ca..71e5f1488cb 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -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): diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index 5e5a96c78b1..7079cbca155 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -1,8 +1,10 @@ #ifdef USE_RP2040 #include "core.h" -#include "crash_handler.h" #include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER +#include "crash_handler.h" +#endif #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -25,7 +27,9 @@ void arch_restart() { } void arch_init() { +#ifdef USE_RP2040_CRASH_HANDLER rp2040::crash_handler_read_and_clear(); +#endif #if USE_RP2040_WATCHDOG_TIMEOUT > 0 watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); #endif diff --git a/esphome/components/rp2040/crash_handler.cpp b/esphome/components/rp2040/crash_handler.cpp index 6ab46da4449..f9eb42a0f8a 100644 --- a/esphome/components/rp2040/crash_handler.cpp +++ b/esphome/components/rp2040/crash_handler.cpp @@ -1,5 +1,8 @@ #ifdef USE_RP2040 +#include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER + #include "crash_handler.h" #include "esphome/core/log.h" @@ -13,13 +16,19 @@ static constexpr uint32_t EF_LR = 5; static constexpr uint32_t EF_PC = 6; -static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF; +// Version encoded in the magic value: upper 16 bits are sentinel (0xDEAD), +// lower 16 bits are the version number. This avoids using a separate scratch +// register for versioning (we only have 8 total). Future firmware reads the +// sentinel to confirm it's crash data, then the version to know the layout. +static constexpr uint32_t CRASH_MAGIC_SENTINEL = 0xDEAD0000; +static constexpr uint32_t CRASH_DATA_VERSION = 1; +static constexpr uint32_t CRASH_MAGIC_V1 = CRASH_MAGIC_SENTINEL | CRASH_DATA_VERSION; // We only have 8 scratch registers (32 bytes) that survive watchdog reboot. // Use them for the most important data, then scan the stack for code addresses. // // Scratch register layout: -// [0] = magic (CRASH_MAGIC) +// [0] = versioned magic (upper 16 bits = 0xDEAD sentinel, lower 16 bits = version) // [1] = PC (program counter at fault) // [2] = LR (link register from exception frame) // [3] = SP (stack pointer at fault) @@ -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(frame); // Record the bad SP for diagnosis @@ -157,7 +169,7 @@ static void __attribute__((used, noreturn)) hard_fault_handler_c(uint32_t *frame uint32_t pre_fault_sp = reinterpret_cast(post_frame); // Write key registers - watchdog_hw->scratch[0] = CRASH_MAGIC; + watchdog_hw->scratch[0] = CRASH_MAGIC_V1; watchdog_hw->scratch[1] = frame[EF_PC]; watchdog_hw->scratch[2] = frame[EF_LR]; watchdog_hw->scratch[3] = pre_fault_sp; @@ -224,4 +236,5 @@ extern "C" void __attribute__((naked, used)) isr_hardfault() { : "i"(hard_fault_handler_c)); } +#endif // USE_RP2040_CRASH_HANDLER #endif // USE_RP2040 diff --git a/esphome/components/rp2040/crash_handler.h b/esphome/components/rp2040/crash_handler.h index f10db47c234..78e8ede08c8 100644 --- a/esphome/components/rp2040/crash_handler.h +++ b/esphome/components/rp2040/crash_handler.h @@ -2,7 +2,9 @@ #ifdef USE_RP2040 -#include +#include "esphome/core/defines.h" + +#ifdef USE_RP2040_CRASH_HANDLER namespace esphome::rp2040 { @@ -12,6 +14,10 @@ void crash_handler_read_and_clear(); /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); +/// Returns true if crash data was found this boot. +bool crash_handler_has_data(); + } // namespace esphome::rp2040 +#endif // USE_RP2040_CRASH_HANDLER #endif // USE_RP2040 diff --git a/esphome/components/runtime_image/bmp_decoder.h b/esphome/components/runtime_image/bmp_decoder.h index 73e54f54302..a52a5615849 100644 --- a/esphome/components/runtime_image/bmp_decoder.h +++ b/esphome/components/runtime_image/bmp_decoder.h @@ -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(this->width_ * this->height_); } diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index 45fb42c1160..83f5052fc8a 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -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 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 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 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() { diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 9ebbe72002b..339a699bc97 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -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(optval) != 0)) + return 0; + } +#endif return ::setsockopt(this->fd_, level, optname, optval, optlen); } int listen(int backlog) { return ::listen(this->fd_, backlog); } diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index 16e4d23d3ba..0eece6480f6 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -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 */ diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index fd1b8a95542..96328e68c73 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -5,6 +5,7 @@ #include #include +#include #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(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(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(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(iov[i].iov_base), iov[i].iov_len); + ssize_t err = this->read_locked_(reinterpret_cast(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_); diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index 95931afcf3f..3c27d71062f 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -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_(); diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index c5792198635..bfc4da9926a 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -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(optval) != 0)) + return 0; + } +#endif return lwip_setsockopt(this->fd_, level, optname, optval, optlen); } int listen(int backlog) { return lwip_listen(this->fd_, backlog); } diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index 7f176db09ef..229a61d9b8e 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -24,23 +24,23 @@ class TemplateTextSaverBase { template 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 diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 73081d204b4..092df6fdca3 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -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); diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 566344fa880..4e623942ac5 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -88,16 +88,16 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { struct timeval timev { .tv_sec = static_cast(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(); diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2040.cpp index 858f1a02ddf..6f6f1fb96b7 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2040.cpp @@ -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; diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 5590e67b822..40830196433 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -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 diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index dbbcd10d8df..3e1baf34bad 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -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); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 54421c851e5..48e13ad71e2 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -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_; } diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 2808d313111..9f73b1cc6f5 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -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]: diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 60764955cc9..09f883ed617 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -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 && diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index f340b708c90..883cc1344b4 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -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_; } diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index eee7fb3f4f8..1d105a10572 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -76,9 +76,7 @@ void Mutex::unlock() { k_mutex_unlock(static_cast(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) { diff --git a/esphome/const.py b/esphome/const.py index eb49c9a1d77..2466f2c49c0 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.3.0b1" +__version__ = "2026.3.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( diff --git a/esphome/core/defines.h b/esphome/core/defines.h index cec77fe2e27..073170aafbf 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -195,6 +195,7 @@ // ESP32-specific feature flags #ifdef USE_ESP32 +#define USE_ESP32_CRASH_HANDLER #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER #define USE_OTA_ROLLBACK @@ -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 diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 70ac1574f0c..dafd899ae43 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -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. diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index c578a9aae91..a695fa396bc 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -112,6 +112,7 @@ // LwIP headers must come first — they define netconn_callback, struct lwip_sock, etc. #include #include +#include // FreeRTOS include paths differ: ESP-IDF uses freertos/ prefix, LibreTiny does not #ifdef USE_ESP32 #include @@ -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; diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 46c6b711cd2..50706ba9f69 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -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. diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index acd7f7a4798..df651ae15dd 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -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: diff --git a/esphome/mqtt.py b/esphome/mqtt.py index cbf78bd3f6e..ccacbaea54f 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -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: diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index 5d4065207f0..cb080b2a953 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -340,6 +340,8 @@ STACKTRACE_ESP32_BACKTRACE_RE = re.compile( r"Backtrace:(?:\s*0x[0-9a-fA-F]{8}:0x[0-9a-fA-F]{8})+" ) STACKTRACE_ESP32_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") +# ESP32 crash handler (stored backtrace from previous boot) +STACKTRACE_ESP32_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") @@ -371,6 +373,11 @@ def process_stacktrace(config, line, backtrace_state): ) _decode_pc(config, match.group(1)) + # ESP32 crash handler backtrace (from previous boot) + match = re.search(STACKTRACE_ESP32_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # ESP32 single-line backtrace match = re.match(STACKTRACE_ESP32_BACKTRACE_RE, line) if match is not None: diff --git a/requirements.txt b/requirements.txt index 3da2d52b44b..e634bcb1046 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.4 +tornado==6.5.5 tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index b4044c362c6..dff6c7690a0 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -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("'"); } diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..4cc865bb5d3 --- /dev/null +++ b/tests/components/adc/test.rp2040-pico2-ard.yaml @@ -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 diff --git a/tests/components/ethernet/common-w5500.yaml b/tests/components/ethernet/common-w5500.yaml index 1f8b8650dd0..bf3f6f3f0c4 100644 --- a/tests/components/ethernet/common-w5500.yaml +++ b/tests/components/ethernet/common-w5500.yaml @@ -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 diff --git a/tests/components/ethernet/test.esp32-p4-idf.yaml b/tests/components/ethernet/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..e52329d7ea2 --- /dev/null +++ b/tests/components/ethernet/test.esp32-p4-idf.yaml @@ -0,0 +1 @@ +<<: !include common-ip101.yaml diff --git a/tests/components/ethernet/test.esp32-s3-idf.yaml b/tests/components/ethernet/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..36f1b5365f1 --- /dev/null +++ b/tests/components/ethernet/test.esp32-s3-idf.yaml @@ -0,0 +1 @@ +<<: !include common-w5500.yaml diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index e5fab62a793..e1216e7b60b 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -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: diff --git a/tests/components/modbus/modbus_test.cpp b/tests/components/modbus/modbus_test.cpp new file mode 100644 index 00000000000..afe5ced082b --- /dev/null +++ b/tests/components/modbus/modbus_test.cpp @@ -0,0 +1,59 @@ +#include +#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 &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 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 diff --git a/tests/components/spi/test.rp2040-pico2-ard.yaml b/tests/components/spi/test.rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..81a8acafd88 --- /dev/null +++ b/tests/components/spi/test.rp2040-pico2-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + clk_pin: GPIO2 + mosi_pin: GPIO3 + miso_pin: GPIO4 + +<<: !include common.yaml diff --git a/tests/components/uart/test.rp2040-ard.yaml b/tests/components/uart/test.rp2040-ard.yaml index 5eb2b533ea0..1d5f91c6a7d 100644 --- a/tests/components/uart/test.rp2040-ard.yaml +++ b/tests/components/uart/test.rp2040-ard.yaml @@ -23,3 +23,6 @@ uart: baud_rate: 115200 debug: debug_prefix: "[UART1] " + - id: uart_rx_only + rx_pin: 17 + baud_rate: 1200 diff --git a/tests/integration/fixtures/online_image_bmp.yaml b/tests/integration/fixtures/online_image_bmp.yaml new file mode 100644 index 00000000000..e36514e9ae2 --- /dev/null +++ b/tests/integration/fixtures/online_image_bmp.yaml @@ -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 diff --git a/tests/integration/fixtures/template_text_save.yaml b/tests/integration/fixtures/template_text_save.yaml new file mode 100644 index 00000000000..526561732de --- /dev/null +++ b/tests/integration/fixtures/template_text_save.yaml @@ -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 diff --git a/tests/integration/test_online_image_bmp.py b/tests/integration/test_online_image_bmp.py new file mode 100644 index 00000000000..7c32154fdd6 --- /dev/null +++ b/tests/integration/test_online_image_bmp.py @@ -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 diff --git a/tests/integration/test_template_text_save.py b/tests/integration/test_template_text_save.py new file mode 100644 index 00000000000..47c8e3188ab --- /dev/null +++ b/tests/integration/test_template_text_save.py @@ -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() diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index 096d4c84615..d63d1d69845 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -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" diff --git a/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml b/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..0922a5238e8 --- /dev/null +++ b/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml @@ -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 diff --git a/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml b/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..205beb6e1bb --- /dev/null +++ b/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml @@ -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} diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index 16861442777..e1b3908c249 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -673,6 +673,34 @@ def test_process_stacktrace_bad_alloc( assert state is False +def test_process_stacktrace_esp32_crash_handler( + setup_core: Path, mock_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP32 crash handler backtrace lines.""" + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp32.crash:078]: PC: 0x400D1234 (fault location)" + state = platformio_api.process_stacktrace(config, line_pc, False) + # PC line is matched by existing STACKTRACE_ESP32_PC_RE + mock_decode_pc.assert_called_with(config, "400D1234") + assert state is False + + mock_decode_pc.reset_mock() + + line_bt0 = "[E][esp32.crash:080]: BT0: 0x400D5678 (backtrace)" + state = platformio_api.process_stacktrace(config, line_bt0, False) + mock_decode_pc.assert_called_once_with(config, "400D5678") + assert state is False + + mock_decode_pc.reset_mock() + + line_bt1 = "[E][esp32.crash:080]: BT1: 0x42005ABC (backtrace)" + state = platformio_api.process_stacktrace(config, line_bt1, False) + mock_decode_pc.assert_called_once_with(config, "42005ABC") + assert state is False + + def test_patch_file_downloader_succeeds_first_try() -> None: """Test patch_file_downloader succeeds on first attempt.""" mock_exception_cls = type("PackageException", (Exception,), {})