From 4fc4da6ed2d69d9cdb47d777abc24e0e5e0c29c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Nov 2025 07:35:31 -0600 Subject: [PATCH 01/23] [analyze-memory] Show all core symbols > 100 B instead of top 15 (#11909) --- esphome/analyze_memory/cli.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index 718f42330d..44ade221f8 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -15,6 +15,11 @@ from . import ( class MemoryAnalyzerCLI(MemoryAnalyzer): """Memory analyzer with CLI-specific report generation.""" + # Symbol size threshold for detailed analysis + SYMBOL_SIZE_THRESHOLD: int = ( + 100 # Show symbols larger than this in detailed analysis + ) + # Column width constants COL_COMPONENT: int = 29 COL_FLASH_TEXT: int = 14 @@ -191,14 +196,21 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): f"{len(symbols):>{self.COL_CORE_COUNT}} | {percentage:>{self.COL_CORE_PERCENT - 1}.1f}%" ) - # Top 15 largest core symbols + # All core symbols above threshold lines.append("") - lines.append(f"Top 15 Largest {_COMPONENT_CORE} Symbols:") sorted_core_symbols = sorted( self._esphome_core_symbols, key=lambda x: x[2], reverse=True ) + large_core_symbols = [ + (symbol, demangled, size) + for symbol, demangled, size in sorted_core_symbols + if size > self.SYMBOL_SIZE_THRESHOLD + ] - for i, (symbol, demangled, size) in enumerate(sorted_core_symbols[:15]): + lines.append( + f"{_COMPONENT_CORE} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_core_symbols)} symbols):" + ) + for i, (symbol, demangled, size) in enumerate(large_core_symbols): lines.append(f"{i + 1}. {demangled} ({size:,} B)") lines.append("=" * self.TABLE_WIDTH) @@ -268,13 +280,15 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append(f"Total size: {comp_mem.flash_total:,} B") lines.append("") - # Show all symbols > 100 bytes for better visibility + # Show all symbols above threshold for better visibility large_symbols = [ - (sym, dem, size) for sym, dem, size in sorted_symbols if size > 100 + (sym, dem, size) + for sym, dem, size in sorted_symbols + if size > self.SYMBOL_SIZE_THRESHOLD ] lines.append( - f"{comp_name} Symbols > 100 B ({len(large_symbols)} symbols):" + f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_symbols)} symbols):" ) for i, (symbol, demangled, size) in enumerate(large_symbols): lines.append(f"{i + 1}. {demangled} ({size:,} B)") From 320120883cab070fa21ba187e13f0f3c6447c3b8 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 17 Nov 2025 07:47:54 +1000 Subject: [PATCH 02/23] [lvgl] Migrate lv_font creation into Font class and optimise (#11915) --- esphome/components/font/__init__.py | 2 + esphome/components/font/font.cpp | 263 ++++++++++++++++------- esphome/components/font/font.h | 33 +-- esphome/components/lvgl/__init__.py | 14 +- esphome/components/lvgl/font.cpp | 76 ------- esphome/components/lvgl/lv_validation.py | 5 +- esphome/components/lvgl/lvgl_esphome.h | 26 +-- esphome/components/lvgl/types.py | 1 - tests/components/lvgl/lvgl-package.yaml | 4 +- tests/components/lvgl/test.host.yaml | 6 + 10 files changed, 232 insertions(+), 198 deletions(-) delete mode 100644 esphome/components/lvgl/font.cpp diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 32e803f405..2667dbdbdf 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -486,6 +486,8 @@ class GlyphInfo: def glyph_to_glyphinfo(glyph, font, size, bpp): + # Convert to 32 bit unicode codepoint + glyph = ord(glyph) scale = 256 // (1 << bpp) if not font.is_scalable: sizes = [pt_to_px(x.size) for x in font.available_sizes] diff --git a/esphome/components/font/font.cpp b/esphome/components/font/font.cpp index add403fe98..5e3bf1dd20 100644 --- a/esphome/components/font/font.cpp +++ b/esphome/components/font/font.cpp @@ -6,42 +6,147 @@ namespace esphome { namespace font { - static const char *const TAG = "font"; -// Compare the char at the string position with this char. -// Return true if this char is less than or equal the other. -bool Glyph::compare_to(const uint8_t *str) const { - // 1 -> this->char_ - // 2 -> str - for (uint32_t i = 0;; i++) { - if (this->a_char[i] == '\0') - return true; - if (str[i] == '\0') - return false; - if (this->a_char[i] > str[i]) - return false; - if (this->a_char[i] < str[i]) - return true; +#ifdef USE_LVGL_FONT +const uint8_t *Font::get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter) { + auto *fe = (Font *) font->dsc; + const auto *gd = fe->get_glyph_data_(unicode_letter); + if (gd == nullptr) { + return nullptr; } - // this should not happen - return false; + return gd->data; } -int Glyph::match_length(const uint8_t *str) const { - for (uint32_t i = 0;; i++) { - if (this->a_char[i] == '\0') - return i; - if (str[i] != this->a_char[i]) + +bool Font::get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next) { + auto *fe = (Font *) font->dsc; + const auto *gd = fe->get_glyph_data_(unicode_letter); + if (gd == nullptr) { + return false; + } + dsc->adv_w = gd->advance; + dsc->ofs_x = gd->offset_x; + dsc->ofs_y = fe->height_ - gd->height - gd->offset_y - fe->lv_font_.base_line; + dsc->box_w = gd->width; + dsc->box_h = gd->height; + dsc->is_placeholder = 0; + dsc->bpp = fe->get_bpp(); + return true; +} + +const Glyph *Font::get_glyph_data_(uint32_t unicode_letter) { + if (unicode_letter == this->last_letter_ && this->last_letter_ != 0) + return this->last_data_; + auto *glyph = this->find_glyph(unicode_letter); + if (glyph == nullptr) { + return nullptr; + } + this->last_data_ = glyph; + this->last_letter_ = unicode_letter; + return glyph; +} +#endif + +/** + * Attempt to extract a 32 bit Unicode codepoint from a UTF-8 string. + * If successful, return the codepoint and set the length to the number of bytes read. + * If the end of the string has been reached and a valid codepoint has not been found, return 0 and set the length to + * 0. + * + * @param utf8_str The input string + * @param length Pointer to length storage + * @return The extracted code point + */ +static uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) { + // Safely cast to uint8_t* for correct bitwise operations on bytes + const uint8_t *current = reinterpret_cast(utf8_str); + uint32_t code_point = 0; + uint8_t c1 = *current++; + + // check for end of string + if (c1 == 0) { + *length = 0; + return 0; + } + + // --- 1-Byte Sequence: 0xxxxxxx (ASCII) --- + if (c1 < 0x80) { + // Valid ASCII byte. + code_point = c1; + // Optimization: No need to check for continuation bytes. + } + // --- 2-Byte Sequence: 110xxxxx 10xxxxxx --- + else if ((c1 & 0xE0) == 0xC0) { + uint8_t c2 = *current++; + + // Error Check 1: Check if c2 is a valid continuation byte (10xxxxxx) + if ((c2 & 0xC0) != 0x80) { + *length = 0; return 0; + } + + code_point = (c1 & 0x1F) << 6; + code_point |= (c2 & 0x3F); + + // Error Check 2: Overlong check (2-byte must be > 0x7F) + if (code_point <= 0x7F) { + *length = 0; + return 0; + } } - // this should not happen - return 0; -} -void Glyph::scan_area(int *x1, int *y1, int *width, int *height) const { - *x1 = this->offset_x; - *y1 = this->offset_y; - *width = this->width; - *height = this->height; + // --- 3-Byte Sequence: 1110xxxx 10xxxxxx 10xxxxxx --- + else if ((c1 & 0xF0) == 0xE0) { + uint8_t c2 = *current++; + uint8_t c3 = *current++; + + // Error Check 1: Check continuation bytes + if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80)) { + *length = 0; + return 0; + } + + code_point = (c1 & 0x0F) << 12; + code_point |= (c2 & 0x3F) << 6; + code_point |= (c3 & 0x3F); + + // Error Check 2: Overlong check (3-byte must be > 0x7FF) + // Also check for surrogates (0xD800-0xDFFF) + if (code_point <= 0x7FF || (code_point >= 0xD800 && code_point <= 0xDFFF)) { + *length = 0; + return 0; + } + } + // --- 4-Byte Sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx --- + else if ((c1 & 0xF8) == 0xF0) { + uint8_t c2 = *current++; + uint8_t c3 = *current++; + uint8_t c4 = *current++; + + // Error Check 1: Check continuation bytes + if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80) || ((c4 & 0xC0) != 0x80)) { + *length = 0; + return 0; + } + + code_point = (c1 & 0x07) << 18; + code_point |= (c2 & 0x3F) << 12; + code_point |= (c3 & 0x3F) << 6; + code_point |= (c4 & 0x3F); + + // Error Check 2: Overlong check (4-byte must be > 0xFFFF) + // Also check for valid Unicode range (must be <= 0x10FFFF) + if (code_point <= 0xFFFF || code_point > 0x10FFFF) { + *length = 0; + return 0; + } + } + // --- Invalid leading byte (e.g., 10xxxxxx or 11111xxx) --- + else { + *length = 0; + return 0; + } + *length = current - reinterpret_cast(utf8_str); + return code_point; } Font::Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, @@ -53,82 +158,93 @@ Font::Font(const Glyph *data, int data_nr, int baseline, int height, int descend linegap_(height - baseline - descender), xheight_(xheight), capheight_(capheight), - bpp_(bpp) {} -int Font::match_next_glyph(const uint8_t *str, int *match_length) const { + bpp_(bpp) { +#ifdef USE_LVGL_FONT + this->lv_font_.dsc = this; + this->lv_font_.line_height = this->get_height(); + this->lv_font_.base_line = this->lv_font_.line_height - this->get_baseline(); + this->lv_font_.get_glyph_dsc = get_glyph_dsc_cb; + this->lv_font_.get_glyph_bitmap = get_glyph_bitmap; + this->lv_font_.subpx = LV_FONT_SUBPX_NONE; + this->lv_font_.underline_position = -1; + this->lv_font_.underline_thickness = 1; +#endif +} + +const Glyph *Font::find_glyph(uint32_t codepoint) const { int lo = 0; int hi = this->glyphs_.size() - 1; while (lo != hi) { int mid = (lo + hi + 1) / 2; - if (this->glyphs_[mid].compare_to(str)) { + if (this->glyphs_[mid].is_less_or_equal(codepoint)) { lo = mid; } else { hi = mid - 1; } } - *match_length = this->glyphs_[lo].match_length(str); - if (*match_length <= 0) - return -1; - return lo; + auto *result = &this->glyphs_[lo]; + if (result->code_point == codepoint) + return result; + return nullptr; } + #ifdef USE_DISPLAY void Font::measure(const char *str, int *width, int *x_offset, int *baseline, int *height) { *baseline = this->baseline_; *height = this->height_; - int i = 0; int min_x = 0; bool has_char = false; int x = 0; - while (str[i] != '\0') { - int match_length; - int glyph_n = this->match_next_glyph((const uint8_t *) str + i, &match_length); - if (glyph_n < 0) { + for (;;) { + size_t length; + auto code_point = extract_unicode_codepoint(str, &length); + if (length == 0) + break; + str += length; + auto *glyph = this->find_glyph(code_point); + if (glyph == nullptr) { // Unknown char, skip - if (!this->get_glyphs().empty()) - x += this->get_glyphs()[0].advance; - i++; + if (!this->glyphs_.empty()) + x += this->glyphs_[0].advance; continue; } - const Glyph &glyph = this->glyphs_[glyph_n]; if (!has_char) { - min_x = glyph.offset_x; + min_x = glyph->offset_x; } else { - min_x = std::min(min_x, x + glyph.offset_x); + min_x = std::min(min_x, x + glyph->offset_x); } - x += glyph.advance; + x += glyph->advance; - i += match_length; has_char = true; } *x_offset = min_x; *width = x - min_x; } + void Font::print(int x_start, int y_start, display::Display *display, Color color, const char *text, Color background) { - int i = 0; int x_at = x_start; - int scan_x1, scan_y1, scan_width, scan_height; - while (text[i] != '\0') { - int match_length; - int glyph_n = this->match_next_glyph((const uint8_t *) text + i, &match_length); - if (glyph_n < 0) { + for (;;) { + size_t length; + auto code_point = extract_unicode_codepoint(text, &length); + if (length == 0) + break; + text += length; + auto *glyph = this->find_glyph(code_point); + if (glyph == nullptr) { // Unknown char, skip - ESP_LOGW(TAG, "Encountered character without representation in font: '%c'", text[i]); - if (!this->get_glyphs().empty()) { - uint8_t glyph_width = this->get_glyphs()[0].advance; - display->filled_rectangle(x_at, y_start, glyph_width, this->height_, color); + ESP_LOGW(TAG, "Codepoint 0x%08" PRIx32 " not found in font", code_point); + if (!this->glyphs_.empty()) { + uint8_t glyph_width = this->glyphs_[0].advance; + display->rectangle(x_at, y_start, glyph_width, this->height_, color); x_at += glyph_width; } - - i++; continue; } - const Glyph &glyph = this->get_glyphs()[glyph_n]; - glyph.scan_area(&scan_x1, &scan_y1, &scan_width, &scan_height); - - const uint8_t *data = glyph.data; - const int max_x = x_at + scan_x1 + scan_width; - const int max_y = y_start + scan_y1 + scan_height; + const uint8_t *data = glyph->data; + const int max_x = x_at + glyph->offset_x + glyph->width; + const int max_y = y_start + glyph->offset_y + glyph->height; uint8_t bitmask = 0; uint8_t pixel_data = 0; @@ -141,10 +257,10 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo auto b_g = (float) background.g; auto b_b = (float) background.b; auto b_w = (float) background.w; - for (int glyph_y = y_start + scan_y1; glyph_y != max_y; glyph_y++) { - for (int glyph_x = x_at + scan_x1; glyph_x != max_x; glyph_x++) { + for (int glyph_y = y_start + glyph->offset_y; glyph_y != max_y; glyph_y++) { + for (int glyph_x = x_at + glyph->offset_x; glyph_x != max_x; glyph_x++) { uint8_t pixel = 0; - for (int bit_num = 0; bit_num != this->bpp_; bit_num++) { + for (uint8_t bit_num = 0; bit_num != this->bpp_; bit_num++) { if (bitmask == 0) { pixel_data = progmem_read_byte(data++); bitmask = 0x80; @@ -164,12 +280,9 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo } } } - x_at += glyph.advance; - - i += match_length; + x_at += glyph->advance; } } #endif - } // namespace font } // namespace esphome diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index cb6cc89137..262ded3be4 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -6,6 +6,9 @@ #ifdef USE_DISPLAY #include "esphome/components/display/display.h" #endif +#ifdef USE_LVGL_FONT +#include +#endif namespace esphome { namespace font { @@ -14,9 +17,9 @@ class Font; class Glyph { public: - constexpr Glyph(const char *a_char, const uint8_t *data, int advance, int offset_x, int offset_y, int width, + constexpr Glyph(uint32_t code_point, const uint8_t *data, int advance, int offset_x, int offset_y, int width, int height) - : a_char(a_char), + : code_point(code_point), data(data), advance(advance), offset_x(offset_x), @@ -24,24 +27,15 @@ class Glyph { width(width), height(height) {} - const uint8_t *get_char() const { return reinterpret_cast(this->a_char); } + bool is_less_or_equal(uint32_t other) const { return this->code_point <= other; } - bool compare_to(const uint8_t *str) const; - - int match_length(const uint8_t *str) const; - - void scan_area(int *x1, int *y1, int *width, int *height) const; - - const char *a_char; + const uint32_t code_point; const uint8_t *data; int advance; int offset_x; int offset_y; int width; int height; - - protected: - friend Font; }; class Font @@ -64,7 +58,7 @@ class Font Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, uint8_t bpp = 1); - int match_next_glyph(const uint8_t *str, int *match_length) const; + const Glyph *find_glyph(uint32_t codepoint) const; #ifdef USE_DISPLAY void print(int x_start, int y_start, display::Display *display, Color color, const char *text, @@ -79,6 +73,9 @@ class Font inline int get_xheight() { return this->xheight_; } inline int get_capheight() { return this->capheight_; } inline int get_bpp() { return this->bpp_; } +#ifdef USE_LVGL_FONT + const lv_font_t *get_lv_font() const { return &this->lv_font_; } +#endif const ConstVector &get_glyphs() const { return glyphs_; } @@ -91,6 +88,14 @@ class Font int xheight_; int capheight_; uint8_t bpp_; // bits per pixel +#ifdef USE_LVGL_FONT + lv_font_t lv_font_{}; + static const uint8_t *get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter); + static bool get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next); + const Glyph *get_glyph_data_(uint32_t unicode_letter); + uint32_t last_letter_{}; + const Glyph *last_data_{}; +#endif }; } // namespace font diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 2a24f343c3..eaa37b54dd 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -52,15 +52,7 @@ from .schemas import ( from .styles import add_top_layer, styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code from .trigger import add_on_boot_triggers, generate_triggers -from .types import ( - FontEngine, - IdleTrigger, - PlainTrigger, - lv_font_t, - lv_group_t, - lv_style_t, - lvgl_ns, -) +from .types import IdleTrigger, PlainTrigger, lv_font_t, lv_group_t, lv_style_t, lvgl_ns from .widgets import ( LvScrActType, Widget, @@ -244,7 +236,6 @@ async def to_code(configs): cg.add_global(lvgl_ns.using) for font in helpers.esphome_fonts_used: await cg.get_variable(font) - cg.new_Pvariable(ID(f"{font}_engine", True, type=FontEngine), MockObj(font)) default_font = config_0[df.CONF_DEFAULT_FONT] if not lvalid.is_lv_font(default_font): add_define( @@ -256,7 +247,8 @@ async def to_code(configs): type=lv_font_t.operator("ptr").operator("const"), ) cg.new_variable( - globfont_id, MockObj(await lvalid.lv_font.process(default_font)) + globfont_id, + MockObj(await lvalid.lv_font.process(default_font), "->").get_lv_font(), ) add_define("LV_FONT_DEFAULT", df.DEFAULT_ESPHOME_FONT) else: diff --git a/esphome/components/lvgl/font.cpp b/esphome/components/lvgl/font.cpp deleted file mode 100644 index 1976fb9608..0000000000 --- a/esphome/components/lvgl/font.cpp +++ /dev/null @@ -1,76 +0,0 @@ -#include "lvgl_esphome.h" - -#ifdef USE_LVGL_FONT -namespace esphome { -namespace lvgl { - -static const uint8_t *get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter) { - auto *fe = (FontEngine *) font->dsc; - const auto *gd = fe->get_glyph_data(unicode_letter); - if (gd == nullptr) - return nullptr; - // esph_log_d(TAG, "Returning bitmap @ %X", (uint32_t)gd->data); - - return gd->data; -} - -static bool get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next) { - auto *fe = (FontEngine *) font->dsc; - const auto *gd = fe->get_glyph_data(unicode_letter); - if (gd == nullptr) - return false; - dsc->adv_w = gd->advance; - dsc->ofs_x = gd->offset_x; - dsc->ofs_y = fe->height - gd->height - gd->offset_y - fe->baseline; - dsc->box_w = gd->width; - dsc->box_h = gd->height; - dsc->is_placeholder = 0; - dsc->bpp = fe->bpp; - return true; -} - -FontEngine::FontEngine(font::Font *esp_font) : font_(esp_font) { - this->bpp = esp_font->get_bpp(); - this->lv_font_.dsc = this; - this->lv_font_.line_height = this->height = esp_font->get_height(); - this->lv_font_.base_line = this->baseline = this->lv_font_.line_height - esp_font->get_baseline(); - this->lv_font_.get_glyph_dsc = get_glyph_dsc_cb; - this->lv_font_.get_glyph_bitmap = get_glyph_bitmap; - this->lv_font_.subpx = LV_FONT_SUBPX_NONE; - this->lv_font_.underline_position = -1; - this->lv_font_.underline_thickness = 1; -} - -const lv_font_t *FontEngine::get_lv_font() { return &this->lv_font_; } - -const font::Glyph *FontEngine::get_glyph_data(uint32_t unicode_letter) { - if (unicode_letter == last_letter_) - return this->last_data_; - uint8_t unicode[5]; - memset(unicode, 0, sizeof unicode); - if (unicode_letter > 0xFFFF) { - unicode[0] = 0xF0 + ((unicode_letter >> 18) & 0x7); - unicode[1] = 0x80 + ((unicode_letter >> 12) & 0x3F); - unicode[2] = 0x80 + ((unicode_letter >> 6) & 0x3F); - unicode[3] = 0x80 + (unicode_letter & 0x3F); - } else if (unicode_letter > 0x7FF) { - unicode[0] = 0xE0 + ((unicode_letter >> 12) & 0xF); - unicode[1] = 0x80 + ((unicode_letter >> 6) & 0x3F); - unicode[2] = 0x80 + (unicode_letter & 0x3F); - } else if (unicode_letter > 0x7F) { - unicode[0] = 0xC0 + ((unicode_letter >> 6) & 0x1F); - unicode[1] = 0x80 + (unicode_letter & 0x3F); - } else { - unicode[0] = unicode_letter; - } - int match_length; - int glyph_n = this->font_->match_next_glyph(unicode, &match_length); - if (glyph_n < 0) - return nullptr; - this->last_data_ = &this->font_->get_glyphs()[glyph_n]; - this->last_letter_ = unicode_letter; - return this->last_data_; -} -} // namespace lvgl -} // namespace esphome -#endif // USES_LVGL_FONT diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 045258555c..23c322c31f 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -493,6 +493,7 @@ class LvFont(LValidator): return LV_FONTS if is_lv_font(value): return lv_builtin_font(value) + add_lv_use("font") fontval = cv.use_id(Font)(value) esphome_fonts_used.add(fontval) return requires_component("font")(fontval) @@ -502,7 +503,9 @@ class LvFont(LValidator): async def process(self, value, args=()): if is_lv_font(value): return literal(f"&lv_font_{value}") - return literal(f"{value}_engine->get_lv_font()") + if isinstance(value, str): + return literal(f"{value}") + return await super().process(value, args) lv_font = LvFont() diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 196a0d1cb4..bd6f1fdb61 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -50,6 +50,14 @@ static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BIT static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_332; #endif // LV_COLOR_DEPTH +#ifdef USE_LVGL_FONT +inline void lv_obj_set_style_text_font(lv_obj_t *obj, const font::Font *font, lv_style_selector_t part) { + lv_obj_set_style_text_font(obj, font->get_lv_font(), part); +} +inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { + lv_style_set_text_font(style, font->get_lv_font()); +} +#endif #ifdef USE_LVGL_IMAGE // Shortcut / overload, so that the source of an image can easily be updated // from within a lambda. @@ -134,24 +142,6 @@ template class ObjUpdateAction : public Action { protected: std::function lamb_; }; -#ifdef USE_LVGL_FONT -class FontEngine { - public: - FontEngine(font::Font *esp_font); - const lv_font_t *get_lv_font(); - - const font::Glyph *get_glyph_data(uint32_t unicode_letter); - uint16_t baseline{}; - uint16_t height{}; - uint8_t bpp{}; - - protected: - font::Font *font_{}; - uint32_t last_letter_{}; - const font::Glyph *last_data_{}; - lv_font_t lv_font_{}; -}; -#endif // USE_LVGL_FONT #ifdef USE_LVGL_ANIMIMG void lv_animimg_stop(lv_obj_t *obj); #endif // USE_LVGL_ANIMIMG diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 035320b6ac..b99c0ad5a3 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -45,7 +45,6 @@ lv_coord_t = cg.global_ns.namespace("lv_coord_t") lv_event_code_t = cg.global_ns.enum("lv_event_code_t") lv_indev_type_t = cg.global_ns.enum("lv_indev_type_t") lv_key_t = cg.global_ns.enum("lv_key_t") -FontEngine = lvgl_ns.class_("FontEngine") PlainTrigger = esphome_ns.class_("Trigger<>", automation.Trigger.template()) DrawEndTrigger = esphome_ns.class_( "Trigger", automation.Trigger.template(cg.uint32, cg.uint32) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index d7c342b16e..e42a813b40 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -76,7 +76,7 @@ lvgl: line_width: 8 line_rounded: true - id: date_style - text_font: roboto10 + text_font: !lambda return id(roboto10); align: center text_color: !lambda return color_id2; bg_opa: cover @@ -267,7 +267,7 @@ lvgl: snprintf(buf, sizeof(buf), "Setup: %d", 42); return std::string(buf); align: top_mid - text_font: space16 + text_font: !lambda return id(space16); - label: id: chip_info_label # Test complex setup lambda (real-world pattern) diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 39d9a0ebf3..00a8cd8c01 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -18,6 +18,7 @@ touchscreen: lvgl: - id: lvgl_0 + default_font: space16 displays: sdl0 - id: lvgl_1 displays: sdl1 @@ -39,3 +40,8 @@ lvgl: text: Click ME on_click: logger.log: Clicked + +font: + - file: "gfonts://Roboto" + id: space16 + bpp: 4 From 986d3c8f137aad0a6097606fae49b98a22949258 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Nov 2025 18:38:38 -0600 Subject: [PATCH 03/23] [sntp] Merge multiple instances to fix crash and undefined behavior (#11904) --- esphome/components/sntp/time.py | 68 +++++ tests/component_tests/sntp/__init__.py | 1 + .../sntp/config/sntp_test.yaml | 22 ++ tests/component_tests/sntp/test_init.py | 238 ++++++++++++++++++ 4 files changed, 329 insertions(+) create mode 100644 tests/component_tests/sntp/__init__.py create mode 100644 tests/component_tests/sntp/config/sntp_test.yaml create mode 100644 tests/component_tests/sntp/test_init.py diff --git a/esphome/components/sntp/time.py b/esphome/components/sntp/time.py index d27fc9991d..69a2436d3d 100644 --- a/esphome/components/sntp/time.py +++ b/esphome/components/sntp/time.py @@ -1,9 +1,14 @@ +import logging + import esphome.codegen as cg from esphome.components import time as time_ +from esphome.config_helpers import merge_config import esphome.config_validation as cv from esphome.const import ( CONF_ID, + CONF_PLATFORM, CONF_SERVERS, + CONF_TIME, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -12,13 +17,74 @@ from esphome.const import ( PLATFORM_RTL87XX, ) from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["network"] + +CONF_SNTP = "sntp" + sntp_ns = cg.esphome_ns.namespace("sntp") SNTPComponent = sntp_ns.class_("SNTPComponent", time_.RealTimeClock) DEFAULT_SERVERS = ["0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org"] + +def _sntp_final_validate(config: ConfigType) -> None: + """Merge multiple SNTP instances into one, similar to OTA merging behavior.""" + full_conf = fv.full_config.get() + time_confs = full_conf.get(CONF_TIME, []) + + sntp_configs: list[ConfigType] = [] + other_time_configs: list[ConfigType] = [] + + for time_conf in time_confs: + if time_conf.get(CONF_PLATFORM) == CONF_SNTP: + sntp_configs.append(time_conf) + else: + other_time_configs.append(time_conf) + + if len(sntp_configs) <= 1: + return + + # Merge all SNTP configs into the first one + merged = sntp_configs[0] + for sntp_conf in sntp_configs[1:]: + # Validate that IDs are consistent if manually specified + if merged[CONF_ID].is_manual and sntp_conf[CONF_ID].is_manual: + raise cv.Invalid( + f"Found multiple SNTP configurations but {CONF_ID} is inconsistent" + ) + merged = merge_config(merged, sntp_conf) + + # Deduplicate servers while preserving order + servers = merged[CONF_SERVERS] + unique_servers = list(dict.fromkeys(servers)) + + # Warn if we're dropping servers due to 3-server limit + if len(unique_servers) > 3: + dropped = unique_servers[3:] + unique_servers = unique_servers[:3] + _LOGGER.warning( + "SNTP supports maximum 3 servers. Dropped excess server(s): %s", + dropped, + ) + + merged[CONF_SERVERS] = unique_servers + + _LOGGER.warning( + "Found and merged %d SNTP time configurations into one instance", + len(sntp_configs), + ) + + # Replace time configs with merged SNTP + other time platforms + other_time_configs.append(merged) + full_conf[CONF_TIME] = other_time_configs + fv.full_config.set(full_conf) + + CONFIG_SCHEMA = cv.All( time_.TIME_SCHEMA.extend( { @@ -40,6 +106,8 @@ CONFIG_SCHEMA = cv.All( ), ) +FINAL_VALIDATE_SCHEMA = _sntp_final_validate + async def to_code(config): servers = config[CONF_SERVERS] diff --git a/tests/component_tests/sntp/__init__.py b/tests/component_tests/sntp/__init__.py new file mode 100644 index 0000000000..7d323a4980 --- /dev/null +++ b/tests/component_tests/sntp/__init__.py @@ -0,0 +1 @@ +"""Tests for SNTP component.""" diff --git a/tests/component_tests/sntp/config/sntp_test.yaml b/tests/component_tests/sntp/config/sntp_test.yaml new file mode 100644 index 0000000000..3942c9606b --- /dev/null +++ b/tests/component_tests/sntp/config/sntp_test.yaml @@ -0,0 +1,22 @@ +esphome: + name: sntp-test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "testssid" + password: "testpassword" + +# Test multiple SNTP instances that should be merged +time: + - platform: sntp + servers: + - 192.168.1.1 + - pool.ntp.org + - platform: sntp + servers: + - pool.ntp.org + - 192.168.1.2 diff --git a/tests/component_tests/sntp/test_init.py b/tests/component_tests/sntp/test_init.py new file mode 100644 index 0000000000..9197ff55d0 --- /dev/null +++ b/tests/component_tests/sntp/test_init.py @@ -0,0 +1,238 @@ +"""Tests for SNTP time configuration validation.""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sntp.time import CONF_SNTP, _sntp_final_validate +from esphome.const import CONF_ID, CONF_PLATFORM, CONF_SERVERS, CONF_TIME +from esphome.core import ID +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + ("time_configs", "expected_count", "expected_servers", "warning_messages"), + [ + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + } + ], + 1, + ["192.168.1.1", "pool.ntp.org"], + [], + id="single_instance_no_merge", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="two_instances_merged", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="deduplication_preserves_order", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2", "pool2.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_3", is_manual=False), + CONF_SERVERS: ["pool3.ntp.org"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + [ + "SNTP supports maximum 3 servers. Dropped excess server(s): ['pool2.ntp.org', 'pool3.ntp.org']", + "Found and merged 3 SNTP time configurations into one instance", + ], + id="three_instances_drops_excess_servers", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: [ + "192.168.1.1", + "pool.ntp.org", + "pool.ntp.org", + "192.168.1.1", + ], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="deduplication_multiple_duplicates", + ), + ], +) +def test_sntp_instance_merging( + time_configs: list[dict[str, Any]], + expected_count: int, + expected_servers: list[str], + warning_messages: list[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test SNTP instance merging behavior.""" + # Create a mock full config with time configs + full_conf = {CONF_TIME: time_configs.copy()} + + # Set the context var + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _sntp_final_validate({}) + + # Get the updated config + updated_conf = fv.full_config.get() + + # Check if merging occurred + if len(time_configs) > 1: + # Verify only one SNTP instance remains + sntp_instances = [ + tc + for tc in updated_conf[CONF_TIME] + if tc.get(CONF_PLATFORM) == CONF_SNTP + ] + assert len(sntp_instances) == expected_count + + # Verify server list + assert sntp_instances[0][CONF_SERVERS] == expected_servers + + # Verify warnings + for expected_msg in warning_messages: + assert any( + expected_msg in record.message for record in caplog.records + ), f"Expected warning message '{expected_msg}' not found in log" + else: + # Single instance should not trigger merging or warnings + assert len(caplog.records) == 0 + # Config should be unchanged + assert updated_conf[CONF_TIME] == time_configs + finally: + fv.full_config.reset(token) + + +def test_sntp_inconsistent_manual_ids() -> None: + """Test that inconsistent manual IDs raise an error.""" + # Create configs with manual IDs that are inconsistent + time_configs = [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=True), + CONF_SERVERS: ["192.168.1.1"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=True), + CONF_SERVERS: ["192.168.1.2"], + }, + ] + + full_conf = {CONF_TIME: time_configs} + + token = fv.full_config.set(full_conf) + try: + with pytest.raises( + cv.Invalid, + match="Found multiple SNTP configurations but id is inconsistent", + ): + _sntp_final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> None: + """Test that SNTP merging doesn't affect other time platforms.""" + time_configs = [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1"], + }, + { + CONF_PLATFORM: "homeassistant", + CONF_ID: ID("homeassistant_time", is_manual=False), + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2"], + }, + ] + + full_conf = {CONF_TIME: time_configs.copy()} + + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _sntp_final_validate({}) + + updated_conf = fv.full_config.get() + + # Should have 2 time platforms: 1 merged SNTP + 1 homeassistant + assert len(updated_conf[CONF_TIME]) == 2 + + # Find the platforms + platforms = {tc[CONF_PLATFORM] for tc in updated_conf[CONF_TIME]} + assert platforms == {CONF_SNTP, "homeassistant"} + + # Verify SNTP was merged + sntp_instances = [ + tc for tc in updated_conf[CONF_TIME] if tc[CONF_PLATFORM] == CONF_SNTP + ] + assert len(sntp_instances) == 1 + assert sntp_instances[0][CONF_SERVERS] == ["192.168.1.1", "192.168.1.2"] + finally: + fv.full_config.reset(token) From 96ee38759d7f5a6dd0e9ea7ca4331007ef49e936 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Nov 2025 18:38:52 -0600 Subject: [PATCH 04/23] [web_server.ota] Merge multiple instances to prevent undefined behavior (#11905) --- esphome/components/web_server/ota/__init__.py | 58 ++++++- .../ota/test_web_server_ota.py | 153 ++++++++++++++++++ 2 files changed, 210 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server/ota/__init__.py b/esphome/components/web_server/ota/__init__.py index 4a98db8877..260e6aea6d 100644 --- a/esphome/components/web_server/ota/__init__.py +++ b/esphome/components/web_server/ota/__init__.py @@ -1,10 +1,17 @@ +import logging + import esphome.codegen as cg from esphome.components.esp32 import add_idf_component from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code +from esphome.config_helpers import merge_config import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_OTA, CONF_PLATFORM, CONF_WEB_SERVER from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network", "web_server_base"] @@ -12,6 +19,53 @@ DEPENDENCIES = ["network", "web_server_base"] web_server_ns = cg.esphome_ns.namespace("web_server") WebServerOTAComponent = web_server_ns.class_("WebServerOTAComponent", OTAComponent) + +def _web_server_ota_final_validate(config: ConfigType) -> None: + """Merge multiple web_server OTA instances into one. + + Multiple web_server OTA instances register duplicate HTTP handlers for /update, + causing undefined behavior. Merge them into a single instance. + """ + full_conf = fv.full_config.get() + ota_confs = full_conf.get(CONF_OTA, []) + + web_server_ota_configs: list[ConfigType] = [] + other_ota_configs: list[ConfigType] = [] + + for ota_conf in ota_confs: + if ota_conf.get(CONF_PLATFORM) == CONF_WEB_SERVER: + web_server_ota_configs.append(ota_conf) + else: + other_ota_configs.append(ota_conf) + + if len(web_server_ota_configs) <= 1: + return + + # Merge all web_server OTA configs into the first one + merged = web_server_ota_configs[0] + for ota_conf in web_server_ota_configs[1:]: + # Validate that IDs are consistent if manually specified + if ( + merged[CONF_ID].is_manual + and ota_conf[CONF_ID].is_manual + and merged[CONF_ID] != ota_conf[CONF_ID] + ): + raise cv.Invalid( + f"Found multiple web_server OTA configurations but {CONF_ID} is inconsistent" + ) + merged = merge_config(merged, ota_conf) + + _LOGGER.warning( + "Found and merged %d web_server OTA configurations into one instance", + len(web_server_ota_configs), + ) + + # Replace OTA configs with merged web_server + other OTA platforms + other_ota_configs.append(merged) + full_conf[CONF_OTA] = other_ota_configs + fv.full_config.set(full_conf) + + CONFIG_SCHEMA = ( cv.Schema( { @@ -22,6 +76,8 @@ CONFIG_SCHEMA = ( .extend(cv.COMPONENT_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = _web_server_ota_final_validate + @coroutine_with_priority(CoroPriority.WEB_SERVER_OTA) async def to_code(config): diff --git a/tests/component_tests/ota/test_web_server_ota.py b/tests/component_tests/ota/test_web_server_ota.py index 0d8ff6f134..794eaac9be 100644 --- a/tests/component_tests/ota/test_web_server_ota.py +++ b/tests/component_tests/ota/test_web_server_ota.py @@ -1,6 +1,18 @@ """Tests for the web_server OTA platform.""" +from __future__ import annotations + from collections.abc import Callable +import logging +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.web_server.ota import _web_server_ota_final_validate +from esphome.const import CONF_ID, CONF_OTA, CONF_PLATFORM, CONF_WEB_SERVER +from esphome.core import ID +import esphome.final_validate as fv def test_web_server_ota_generated(generate_main: Callable[[str], str]) -> None: @@ -100,3 +112,144 @@ def test_web_server_ota_esp8266(generate_main: Callable[[str], str]) -> None: # Check web server OTA component is present assert "WebServerOTAComponent" in main_cpp assert "web_server::WebServerOTAComponent" in main_cpp + + +@pytest.mark.parametrize( + ("ota_configs", "expected_count", "warning_expected"), + [ + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web", is_manual=False), + } + ], + 1, + False, + id="single_instance_no_merge", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=False), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=False), + }, + ], + 1, + True, + id="two_instances_merged", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=False), + }, + { + CONF_PLATFORM: "esphome", + CONF_ID: ID("ota_esphome", is_manual=False), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=False), + }, + ], + 2, + True, + id="mixed_platforms_web_server_merged", + ), + ], +) +def test_web_server_ota_instance_merging( + ota_configs: list[dict[str, Any]], + expected_count: int, + warning_expected: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test web_server OTA instance merging behavior.""" + full_conf = {CONF_OTA: ota_configs.copy()} + + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _web_server_ota_final_validate({}) + + updated_conf = fv.full_config.get() + + # Verify total number of OTA platforms + assert len(updated_conf[CONF_OTA]) == expected_count + + # Verify warning + if warning_expected: + assert any( + "Found and merged" in record.message + and "web_server OTA" in record.message + for record in caplog.records + ), "Expected merge warning not found in log" + else: + assert len(caplog.records) == 0, "Unexpected warnings logged" + finally: + fv.full_config.reset(token) + + +def test_web_server_ota_consistent_manual_ids( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that consistent manual IDs can be merged successfully.""" + ota_configs = [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web", is_manual=True), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web", is_manual=True), + }, + ] + + full_conf = {CONF_OTA: ota_configs} + + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _web_server_ota_final_validate({}) + + updated_conf = fv.full_config.get() + assert len(updated_conf[CONF_OTA]) == 1 + assert updated_conf[CONF_OTA][0][CONF_ID].id == "ota_web" + assert any( + "Found and merged" in record.message and "web_server OTA" in record.message + for record in caplog.records + ) + finally: + fv.full_config.reset(token) + + +def test_web_server_ota_inconsistent_manual_ids() -> None: + """Test that inconsistent manual IDs raise an error.""" + ota_configs = [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=True), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=True), + }, + ] + + full_conf = {CONF_OTA: ota_configs} + + token = fv.full_config.set(full_conf) + try: + with pytest.raises( + cv.Invalid, + match="Found multiple web_server OTA configurations but id is inconsistent", + ): + _web_server_ota_final_validate({}) + finally: + fv.full_config.reset(token) From 3b860e784c462ee07c74b79a0393d29f635d876d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Nov 2025 18:39:01 -0600 Subject: [PATCH 05/23] [web_server_idf] Fix lwIP assertion crash by shutting down sockets on connection close (#11937) --- .../components/web_server_idf/web_server_idf.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 0dab5e7e8c..ce91569de2 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -489,10 +489,18 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * void AsyncEventSourceResponse::destroy(void *ptr) { auto *rsp = static_cast(ptr); - ESP_LOGD(TAG, "Event source connection closed (fd: %d)", rsp->fd_.load()); - // Mark as dead by setting fd to 0 - will be cleaned up in the main loop - rsp->fd_.store(0); - // Note: We don't delete or remove from set here to avoid race conditions + int fd = rsp->fd_.exchange(0); // Atomically get and clear fd + + if (fd > 0) { + ESP_LOGD(TAG, "Event source connection closed (fd: %d)", fd); + // Immediately shut down the socket to prevent lwIP from delivering more data + // This prevents "recv_tcp: recv for wrong pcb!" assertions when the TCP stack + // tries to deliver queued data after the session is marked as dead + // See: https://github.com/esphome/esphome/issues/11936 + shutdown(fd, SHUT_RDWR); + // Note: We don't close() the socket - httpd owns it and will close it + } + // Session will be cleaned up in the main loop to avoid race conditions } // helper for allowing only unique entries in the queue From aa097a2fe6ba533a23b1598d44290c677d03ef04 Mon Sep 17 00:00:00 2001 From: Anton Sergunov Date: Mon, 17 Nov 2025 07:25:00 +0600 Subject: [PATCH 06/23] [uart] Setup uart pins only if flags are set (#11914) Co-authored-by: J. Nick Koston --- .../components/uart/uart_component_esp8266.cpp | 18 +++++++++++++----- .../components/uart/uart_component_esp_idf.cpp | 18 +++++++++++++----- .../uart/uart_component_libretiny.cpp | 2 +- .../components/uart/uart_component_rp2040.cpp | 18 +++++++++++++----- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 7a453dbb50..c84a877ef4 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -56,11 +56,19 @@ uint32_t ESP8266UartComponent::get_config() { } void ESP8266UartComponent::setup() { - if (this->rx_pin_) { - this->rx_pin_->setup(); - } - if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { - this->tx_pin_->setup(); + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); } // Use Arduino HardwareSerial UARTs if all used pins match the ones diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 70a13c9e37..61ca8c1c0c 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -133,11 +133,19 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } - if (this->rx_pin_) { - this->rx_pin_->setup(); - } - if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { - this->tx_pin_->setup(); + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); } int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 8d1d28fce4..1e408b169b 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -53,7 +53,7 @@ void LibreTinyUARTComponent::setup() { auto shouldFallbackToSoftwareSerial = [&]() -> bool { auto hasFlags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> bool { - return pin && pin->get_flags() & mask != gpio::Flags::FLAG_NONE; + return pin && (pin->get_flags() & mask) != gpio::Flags::FLAG_NONE; }; if (hasFlags(this->tx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN) || hasFlags(this->rx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) { diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2040.cpp index c78691653d..cd3905b5c1 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2040.cpp @@ -52,11 +52,19 @@ uint16_t RP2040UartComponent::get_config() { } void RP2040UartComponent::setup() { - if (this->rx_pin_) { - this->rx_pin_->setup(); - } - if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { - this->tx_pin_->setup(); + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); } uint16_t config = get_config(); From aed80732f965ec9dc93fb1bde0dc5d9e603f866e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 14 Nov 2025 00:55:48 +1000 Subject: [PATCH 07/23] [esp32] Make esp-idf default framework for P4 (#11884) --- esphome/components/esp32/__init__.py | 163 ++++++++++++--------------- 1 file changed, 74 insertions(+), 89 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 61511cba0c..9741dc76a1 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -381,8 +381,9 @@ PLATFORM_VERSION_LOOKUP = { } -def _check_versions(value): - value = value.copy() +def _check_versions(config): + config = config.copy() + value = config[CONF_FRAMEWORK] if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP: if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value: @@ -447,7 +448,7 @@ def _check_versions(value): "If there are connectivity or build issues please remove the manual version." ) - return value + return config def _parse_platform_version(value): @@ -598,89 +599,72 @@ def _validate_idf_component(config: ConfigType) -> ConfigType: FRAMEWORK_ESP_IDF = "esp-idf" FRAMEWORK_ARDUINO = "arduino" -FRAMEWORK_SCHEMA = cv.All( - cv.Schema( - { - cv.Optional(CONF_TYPE, default=FRAMEWORK_ARDUINO): cv.one_of( - FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO - ), - cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_RELEASE): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, - cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): { - cv.string_strict: cv.string_strict - }, - cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of( - *LOG_LEVELS_IDF, upper=True - ), - cv.Optional(CONF_ADVANCED, default={}): cv.Schema( - { - cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of( - *ASSERTION_LEVELS, upper=True - ), - cv.Optional(CONF_COMPILER_OPTIMIZATION, default="SIZE"): cv.one_of( - *COMPILER_OPTIMIZATIONS, upper=True - ), - cv.Optional(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES): cv.boolean, - cv.Optional(CONF_ENABLE_LWIP_ASSERT, default=True): cv.boolean, - cv.Optional( - CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False - ): cv.boolean, - cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC): cv.boolean, - # DHCP server is needed for WiFi AP mode. When WiFi component is used, - # it will handle disabling DHCP server when AP is not configured. - # Default to false (disabled) when WiFi is not used. - cv.OnlyWithout( - CONF_ENABLE_LWIP_DHCP_SERVER, "wifi", default=False - ): cv.boolean, - cv.Optional( - CONF_ENABLE_LWIP_MDNS_QUERIES, default=True - ): cv.boolean, - cv.Optional( - CONF_ENABLE_LWIP_BRIDGE_INTERFACE, default=False - ): cv.boolean, - cv.Optional( - CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, default=True - ): cv.boolean, - cv.Optional( - CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, default=True - ): cv.boolean, - cv.Optional( - CONF_DISABLE_LIBC_LOCKS_IN_IRAM, default=True - ): cv.boolean, - cv.Optional( - CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True - ): cv.boolean, - cv.Optional( - CONF_DISABLE_VFS_SUPPORT_SELECT, default=True - ): cv.boolean, - cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean, - cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean, - cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( - min=8192, max=32768 - ), - } - ), - cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( - cv.All( - cv.Schema( - { - cv.Required(CONF_NAME): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.git_ref, - cv.Optional(CONF_REF): cv.string, - cv.Optional(CONF_PATH): cv.string, - cv.Optional(CONF_REFRESH): cv.All( - cv.string, cv.source_refresh - ), - } - ), - _validate_idf_component, - ) - ), - } - ), - _check_versions, +FRAMEWORK_SCHEMA = cv.Schema( + { + cv.Optional(CONF_TYPE): cv.one_of(FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO), + cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, + cv.Optional(CONF_RELEASE): cv.string_strict, + cv.Optional(CONF_SOURCE): cv.string_strict, + cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, + cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): { + cv.string_strict: cv.string_strict + }, + cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of( + *LOG_LEVELS_IDF, upper=True + ), + cv.Optional(CONF_ADVANCED, default={}): cv.Schema( + { + cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of( + *ASSERTION_LEVELS, upper=True + ), + cv.Optional(CONF_COMPILER_OPTIMIZATION, default="SIZE"): cv.one_of( + *COMPILER_OPTIMIZATIONS, upper=True + ), + cv.Optional(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES): cv.boolean, + cv.Optional(CONF_ENABLE_LWIP_ASSERT, default=True): cv.boolean, + cv.Optional(CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False): cv.boolean, + cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC): cv.boolean, + # DHCP server is needed for WiFi AP mode. When WiFi component is used, + # it will handle disabling DHCP server when AP is not configured. + # Default to false (disabled) when WiFi is not used. + cv.OnlyWithout( + CONF_ENABLE_LWIP_DHCP_SERVER, "wifi", default=False + ): cv.boolean, + cv.Optional(CONF_ENABLE_LWIP_MDNS_QUERIES, default=True): cv.boolean, + cv.Optional( + CONF_ENABLE_LWIP_BRIDGE_INTERFACE, default=False + ): cv.boolean, + cv.Optional( + CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, default=True + ): cv.boolean, + cv.Optional( + CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, default=True + ): cv.boolean, + cv.Optional(CONF_DISABLE_LIBC_LOCKS_IN_IRAM, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_VFS_SUPPORT_SELECT, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean, + cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean, + cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( + min=8192, max=32768 + ), + } + ), + cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( + cv.All( + cv.Schema( + { + cv.Required(CONF_NAME): cv.string_strict, + cv.Optional(CONF_SOURCE): cv.git_ref, + cv.Optional(CONF_REF): cv.string, + cv.Optional(CONF_PATH): cv.string, + cv.Optional(CONF_REFRESH): cv.All(cv.string, cv.source_refresh), + } + ), + _validate_idf_component, + ) + ), + } ) @@ -743,11 +727,11 @@ def _show_framework_migration_message(name: str, variant: str) -> None: def _set_default_framework(config): + config = config.copy() if CONF_FRAMEWORK not in config: - config = config.copy() - - variant = config[CONF_VARIANT] config[CONF_FRAMEWORK] = FRAMEWORK_SCHEMA({}) + if CONF_TYPE not in config[CONF_FRAMEWORK]: + variant = config[CONF_VARIANT] if variant in ARDUINO_ALLOWED_VARIANTS: config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ARDUINO _show_framework_migration_message( @@ -787,6 +771,7 @@ CONFIG_SCHEMA = cv.All( ), _detect_variant, _set_default_framework, + _check_versions, set_core_data, cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT), ) From fe00e209fff4f9c2e3e3b4c4664a300503488e94 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:52:08 +1000 Subject: [PATCH 08/23] [esp32] Add sdkconfig flag to make OTA work for 32MB flash (#11883) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 81 +++++++++---------- tests/components/esp32/test.esp32-p4-idf.yaml | 27 +++++++ 2 files changed, 67 insertions(+), 41 deletions(-) create mode 100644 tests/components/esp32/test.esp32-p4-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9741dc76a1..0f85e585f7 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -498,6 +498,8 @@ def final_validate(config): from esphome.components.psram import DOMAIN as PSRAM_DOMAIN errs = [] + conf_fw = config[CONF_FRAMEWORK] + advanced = conf_fw[CONF_ADVANCED] full_config = fv.full_config.get() if pio_options := full_config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS): pio_flash_size_key = "board_upload.flash_size" @@ -514,22 +516,14 @@ def final_validate(config): f"Please specify {CONF_FLASH_SIZE} within esp32 configuration only" ) ) - if ( - config[CONF_VARIANT] != VARIANT_ESP32 - and CONF_ADVANCED in (conf_fw := config[CONF_FRAMEWORK]) - and CONF_IGNORE_EFUSE_MAC_CRC in conf_fw[CONF_ADVANCED] - ): + if config[CONF_VARIANT] != VARIANT_ESP32 and advanced[CONF_IGNORE_EFUSE_MAC_CRC]: errs.append( cv.Invalid( f"'{CONF_IGNORE_EFUSE_MAC_CRC}' is not supported on {config[CONF_VARIANT]}", path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_IGNORE_EFUSE_MAC_CRC], ) ) - if ( - config.get(CONF_FRAMEWORK, {}) - .get(CONF_ADVANCED, {}) - .get(CONF_EXECUTE_FROM_PSRAM) - ): + if advanced[CONF_EXECUTE_FROM_PSRAM]: if config[CONF_VARIANT] != VARIANT_ESP32S3: errs.append( cv.Invalid( @@ -545,6 +539,17 @@ def final_validate(config): ) ) + if ( + config[CONF_FLASH_SIZE] == "32MB" + and "ota" in full_config + and not advanced[CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES] + ): + errs.append( + cv.Invalid( + f"OTA with 32MB flash requires '{CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES}' to be set in the '{CONF_ADVANCED}' section of the esp32 configuration", + path=[CONF_FLASH_SIZE], + ) + ) if errs: raise cv.MultipleInvalid(errs) @@ -620,10 +625,12 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_COMPILER_OPTIMIZATION, default="SIZE"): cv.one_of( *COMPILER_OPTIMIZATIONS, upper=True ), - cv.Optional(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES): cv.boolean, + cv.Optional( + CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES, default=False + ): cv.boolean, cv.Optional(CONF_ENABLE_LWIP_ASSERT, default=True): cv.boolean, cv.Optional(CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False): cv.boolean, - cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC): cv.boolean, + cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC, default=False): cv.boolean, # DHCP server is needed for WiFi AP mode. When WiFi component is used, # it will handle disabling DHCP server when AP is not configured. # Default to false (disabled) when WiFi is not used. @@ -644,7 +651,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_VFS_SUPPORT_SELECT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean, - cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean, + cv.Optional(CONF_EXECUTE_FROM_PSRAM, default=False): cv.boolean, cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( min=8192, max=32768 ), @@ -790,9 +797,7 @@ def _configure_lwip_max_sockets(conf: dict) -> None: from esphome.components.socket import KEY_SOCKET_CONSUMERS # Check if user manually specified CONFIG_LWIP_MAX_SOCKETS - user_max_sockets = conf.get(CONF_SDKCONFIG_OPTIONS, {}).get( - "CONFIG_LWIP_MAX_SOCKETS" - ) + user_max_sockets = conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_LWIP_MAX_SOCKETS") socket_consumers: dict[str, int] = CORE.data.get(KEY_SOCKET_CONSUMERS, {}) total_sockets = sum(socket_consumers.values()) @@ -962,23 +967,18 @@ async def to_code(config): # WiFi component handles its own optimization when AP mode is not used # When using Arduino with Ethernet, DHCP server functions must be available # for the Network library to compile, even if not actively used - if ( - CONF_ENABLE_LWIP_DHCP_SERVER in advanced - and not advanced[CONF_ENABLE_LWIP_DHCP_SERVER] - and not ( - conf[CONF_TYPE] == FRAMEWORK_ARDUINO - and "ethernet" in CORE.loaded_integrations - ) + if advanced.get(CONF_ENABLE_LWIP_DHCP_SERVER) is False and not ( + conf[CONF_TYPE] == FRAMEWORK_ARDUINO and "ethernet" in CORE.loaded_integrations ): add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) - if not advanced.get(CONF_ENABLE_LWIP_MDNS_QUERIES, True): + if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]: add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) - if not advanced.get(CONF_ENABLE_LWIP_BRIDGE_INTERFACE, False): + if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]: add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) _configure_lwip_max_sockets(conf) - if advanced.get(CONF_EXECUTE_FROM_PSRAM, False): + if advanced[CONF_EXECUTE_FROM_PSRAM]: add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True) add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True) @@ -989,23 +989,22 @@ async def to_code(config): # - select() on 4 sockets: ~190μs (Arduino/core locking) vs ~235μs (ESP-IDF default) # - Up to 200% slower under load when all operations queue through tcpip_thread # Enabling this makes ESP-IDF socket performance match Arduino framework. - if advanced.get(CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, True): + if advanced[CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING]: add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_CORE_LOCKING", True) - if advanced.get(CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, True): + if advanced[CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY]: add_idf_sdkconfig_option("CONFIG_LWIP_CHECK_THREAD_SAFETY", True) # Disable placing libc locks in IRAM to save RAM # This is safe for ESPHome since no IRAM ISRs (interrupts that run while cache is disabled) # use libc lock APIs. Saves approximately 1.3KB (1,356 bytes) of IRAM. - if advanced.get(CONF_DISABLE_LIBC_LOCKS_IN_IRAM, True): + if advanced[CONF_DISABLE_LIBC_LOCKS_IN_IRAM]: add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False) # Disable VFS support for termios (terminal I/O functions) # ESPHome doesn't use termios functions on ESP32 (only used in host UART driver). # Saves approximately 1.8KB of flash when disabled (default). add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_TERMIOS", - not advanced.get(CONF_DISABLE_VFS_SUPPORT_TERMIOS, True), + "CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS] ) # Disable VFS support for select() with file descriptors @@ -1019,8 +1018,7 @@ async def to_code(config): else: # No component needs it - allow user to control (default: disabled) add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_SELECT", - not advanced.get(CONF_DISABLE_VFS_SUPPORT_SELECT, True), + "CONFIG_VFS_SUPPORT_SELECT", not advanced[CONF_DISABLE_VFS_SUPPORT_SELECT] ) # Disable VFS support for directory functions (opendir, readdir, mkdir, etc.) @@ -1033,8 +1031,7 @@ async def to_code(config): else: # No component needs it - allow user to control (default: disabled) add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_DIR", - not advanced.get(CONF_DISABLE_VFS_SUPPORT_DIR, True), + "CONFIG_VFS_SUPPORT_DIR", not advanced[CONF_DISABLE_VFS_SUPPORT_DIR] ) cg.add_platformio_option("board_build.partitions", "partitions.csv") @@ -1048,7 +1045,7 @@ async def to_code(config): add_idf_sdkconfig_option(flag, assertion_level == key) add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False) - compiler_optimization = advanced.get(CONF_COMPILER_OPTIMIZATION) + compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION] for key, flag in COMPILER_OPTIMIZATIONS.items(): add_idf_sdkconfig_option(flag, compiler_optimization == key) @@ -1057,18 +1054,20 @@ async def to_code(config): conf[CONF_ADVANCED][CONF_ENABLE_LWIP_ASSERT], ) - if advanced.get(CONF_IGNORE_EFUSE_MAC_CRC): + if advanced[CONF_IGNORE_EFUSE_MAC_CRC]: add_idf_sdkconfig_option("CONFIG_ESP_MAC_IGNORE_MAC_CRC_ERROR", True) add_idf_sdkconfig_option("CONFIG_ESP_PHY_CALIBRATION_AND_DATA_STORAGE", False) - if advanced.get(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES): + if advanced[CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES]: _LOGGER.warning( "Using experimental features in ESP-IDF may result in unexpected failures." ) add_idf_sdkconfig_option("CONFIG_IDF_EXPERIMENTAL_FEATURES", True) + if config[CONF_FLASH_SIZE] == "32MB": + add_idf_sdkconfig_option( + "CONFIG_BOOTLOADER_CACHE_32BIT_ADDR_QUAD_FLASH", True + ) - cg.add_define( - "ESPHOME_LOOP_TASK_STACK_SIZE", advanced.get(CONF_LOOP_TASK_STACK_SIZE) - ) + cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE]) cg.add_define( "USE_ESP_IDF_VERSION_CODE", diff --git a/tests/components/esp32/test.esp32-p4-idf.yaml b/tests/components/esp32/test.esp32-p4-idf.yaml new file mode 100644 index 0000000000..a4c930f236 --- /dev/null +++ b/tests/components/esp32/test.esp32-p4-idf.yaml @@ -0,0 +1,27 @@ +esp32: + variant: esp32p4 + flash_size: 32MB + cpu_frequency: 400MHz + framework: + type: esp-idf + advanced: + enable_idf_experimental_features: yes + +ota: + platform: esphome + +wifi: + ssid: MySSID + password: password1 + +esp32_hosted: + variant: ESP32C6 + slot: 1 + active_high: true + reset_pin: GPIO15 + cmd_pin: GPIO13 + clk_pin: GPIO12 + d0_pin: GPIO11 + d1_pin: GPIO10 + d2_pin: GPIO9 + d3_pin: GPIO8 From eb54c0026dfcab03fbb841ce5fd374554fa61960 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:08:06 +0100 Subject: [PATCH 09/23] [light] Fix missing `ColorMode::BRIGHTNESS` case in logging (#11836) --- esphome/components/light/light_call.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 8365ac77cd..b15ff84b97 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -52,8 +52,10 @@ static void log_invalid_parameter(const char *name, const LogString *message) { } static const LogString *color_mode_to_human(ColorMode color_mode) { - if (color_mode == ColorMode::UNKNOWN) - return LOG_STR("Unknown"); + if (color_mode == ColorMode::ON_OFF) + return LOG_STR("On/Off"); + if (color_mode == ColorMode::BRIGHTNESS) + return LOG_STR("Brightness"); if (color_mode == ColorMode::WHITE) return LOG_STR("White"); if (color_mode == ColorMode::COLOR_TEMPERATURE) @@ -68,7 +70,7 @@ static const LogString *color_mode_to_human(ColorMode color_mode) { return LOG_STR("RGB + cold/warm white"); if (color_mode == ColorMode::RGB_COLOR_TEMPERATURE) return LOG_STR("RGB + color temperature"); - return LOG_STR(""); + return LOG_STR("Unknown"); } // Helper to log percentage values From 7ce94c27feeade6a50082ed9f14165c056fb2a71 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:13:48 +0100 Subject: [PATCH 10/23] [wifi] Allow `use_psram` with Arduino (#11902) --- esphome/components/wifi/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 4dbb425e4b..11bd7798e2 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -12,7 +12,6 @@ from esphome.components.network import ( from esphome.components.psram import is_guaranteed as psram_is_guaranteed from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv -from esphome.config_validation import only_with_esp_idf from esphome.const import ( CONF_AP, CONF_BSSID, @@ -352,7 +351,7 @@ CONFIG_SCHEMA = cv.All( single=True ), cv.Optional(CONF_USE_PSRAM): cv.All( - only_with_esp_idf, cv.requires_component("psram"), cv.boolean + cv.only_on_esp32, cv.requires_component("psram"), cv.boolean ), } ), From 97c4914573b5b7444f44c526e1fb11d13cdcfe38 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:06:08 -0500 Subject: [PATCH 11/23] [uart] Improve error handling and validate buffer size (#11895) Co-authored-by: J. Nick Koston --- esphome/components/uart/__init__.py | 19 ++++++++++ .../uart/uart_component_esp_idf.cpp | 35 +++++++++++++++---- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index cbc11d0db0..7b0d9726b8 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -1,3 +1,4 @@ +from logging import getLogger import math import re @@ -35,6 +36,8 @@ from esphome.core import CORE, ID import esphome.final_validate as fv from esphome.yaml_util import make_data_base +_LOGGER = getLogger(__name__) + CODEOWNERS = ["@esphome/core"] uart_ns = cg.esphome_ns.namespace("uart") UARTComponent = uart_ns.class_("UARTComponent") @@ -130,6 +133,21 @@ def validate_host_config(config): return config +def validate_rx_buffer_size(config): + if CORE.is_esp32: + # ESP32 UART hardware FIFO is 128 bytes (LP UART is 16 bytes, but we use 128 as safe minimum) + # rx_buffer_size must be greater than the hardware FIFO length + min_buffer_size = 128 + if config[CONF_RX_BUFFER_SIZE] <= min_buffer_size: + _LOGGER.warning( + "UART rx_buffer_size (%d bytes) is too small and must be greater than the hardware " + "FIFO size (%d bytes). The buffer size will be automatically adjusted at runtime.", + config[CONF_RX_BUFFER_SIZE], + min_buffer_size, + ) + return config + + def _uart_declare_type(value): if CORE.is_esp8266: return cv.declare_id(ESP8266UartComponent)(value) @@ -247,6 +265,7 @@ CONFIG_SCHEMA = cv.All( ).extend(cv.COMPONENT_SCHEMA), cv.has_at_least_one_key(CONF_TX_PIN, CONF_RX_PIN, CONF_PORT), validate_host_config, + validate_rx_buffer_size, ) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 73813d2d5b..70a13c9e37 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -91,6 +91,16 @@ void IDFUARTComponent::setup() { this->uart_num_ = static_cast(next_uart_num++); this->lock_ = xSemaphoreCreateMutex(); +#if (SOC_UART_LP_NUM >= 1) + size_t fifo_len = ((this->uart_num_ < SOC_UART_HP_NUM) ? SOC_UART_FIFO_LEN : SOC_LP_UART_FIFO_LEN); +#else + size_t fifo_len = SOC_UART_FIFO_LEN; +#endif + if (this->rx_buffer_size_ <= fifo_len) { + ESP_LOGW(TAG, "rx_buffer_size is too small, must be greater than %zu", fifo_len); + this->rx_buffer_size_ = fifo_len * 2; + } + xSemaphoreTake(this->lock_, portMAX_DELAY); this->load_settings(false); @@ -237,8 +247,12 @@ void IDFUARTComponent::set_rx_timeout(size_t rx_timeout) { void IDFUARTComponent::write_array(const uint8_t *data, size_t len) { xSemaphoreTake(this->lock_, portMAX_DELAY); - uart_write_bytes(this->uart_num_, data, len); + int32_t write_len = uart_write_bytes(this->uart_num_, data, len); xSemaphoreGive(this->lock_); + if (write_len != (int32_t) len) { + ESP_LOGW(TAG, "uart_write_bytes failed: %d != %zu", write_len, len); + this->mark_failed(); + } #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { this->debug_callback_.call(UART_DIRECTION_TX, data[i]); @@ -267,6 +281,7 @@ bool IDFUARTComponent::peek_byte(uint8_t *data) { bool IDFUARTComponent::read_array(uint8_t *data, size_t len) { size_t length_to_read = len; + int32_t read_len = 0; if (!this->check_read_timeout_(len)) return false; xSemaphoreTake(this->lock_, portMAX_DELAY); @@ -277,25 +292,31 @@ bool IDFUARTComponent::read_array(uint8_t *data, size_t len) { this->has_peek_ = false; } if (length_to_read > 0) - uart_read_bytes(this->uart_num_, data, length_to_read, 20 / portTICK_PERIOD_MS); + read_len = uart_read_bytes(this->uart_num_, data, length_to_read, 20 / portTICK_PERIOD_MS); xSemaphoreGive(this->lock_); #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { this->debug_callback_.call(UART_DIRECTION_RX, data[i]); } #endif - return true; + return read_len == (int32_t) length_to_read; } int IDFUARTComponent::available() { - size_t available; + size_t available = 0; + esp_err_t err; xSemaphoreTake(this->lock_, portMAX_DELAY); - uart_get_buffered_data_len(this->uart_num_, &available); - if (this->has_peek_) - available++; + err = uart_get_buffered_data_len(this->uart_num_, &available); xSemaphoreGive(this->lock_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_get_buffered_data_len failed: %s", esp_err_to_name(err)); + this->mark_failed(); + } + if (this->has_peek_) { + available++; + } return available; } From 6440b5fbf5d05e7b572d9272c8ea8865b59ed513 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Nov 2025 22:03:43 -0600 Subject: [PATCH 12/23] [ld2412] Fix stuck targets by adding timeout filter (#11919) --- esphome/components/ld2412/sensor.py | 76 ++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/esphome/components/ld2412/sensor.py b/esphome/components/ld2412/sensor.py index abb823faad..0bfbd9bf1d 100644 --- a/esphome/components/ld2412/sensor.py +++ b/esphome/components/ld2412/sensor.py @@ -31,36 +31,84 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(CONF_LD2412_ID): cv.use_id(LD2412Component), cv.Optional(CONF_DETECTION_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_LIGHT): sensor.sensor_schema( device_class=DEVICE_CLASS_ILLUMINANCE, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_LIGHTBULB, unit_of_measurement=UNIT_EMPTY, # No standard unit for this light sensor ), cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_MOVING_ENERGY): sensor.sensor_schema( - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), @@ -74,7 +122,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( cv.Optional(CONF_MOVE_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, filters=[ - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)} + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, ], icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, @@ -82,7 +136,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, filters=[ - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)} + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, ], icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, From d559f9f52e26cc779528fdb5c015cb73560536f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Nov 2025 22:04:25 -0600 Subject: [PATCH 13/23] [ld2410] Add timeout filter to prevent stuck targets (#11920) --- esphome/components/ld2410/sensor.py | 76 ++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/esphome/components/ld2410/sensor.py b/esphome/components/ld2410/sensor.py index fca2b2ceca..3bd34963bc 100644 --- a/esphome/components/ld2410/sensor.py +++ b/esphome/components/ld2410/sensor.py @@ -31,35 +31,83 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(CONF_LD2410_ID): cv.use_id(LD2410Component), cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_STILL_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_MOVING_ENERGY): sensor.sensor_schema( - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_LIGHT): sensor.sensor_schema( device_class=DEVICE_CLASS_ILLUMINANCE, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_LIGHTBULB, ), cv.Optional(CONF_DETECTION_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), @@ -73,7 +121,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( cv.Optional(CONF_MOVE_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, filters=[ - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)} + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, ], icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, @@ -81,7 +135,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, filters=[ - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)} + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, ], icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, From 36868ee7b173628e838951d3fb3c899e66b74866 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Nov 2025 22:20:57 -0600 Subject: [PATCH 14/23] [scheduler] Fix timing breakage after 49 days of uptime on ESP8266/RP2040 (#11924) --- esphome/core/scheduler.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d285af2d0e..d2e0f0dab4 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -609,13 +609,12 @@ uint64_t Scheduler::millis_64_(uint32_t now) { if (now < last && (last - now) > HALF_MAX_UINT32) { this->millis_major_++; major++; + this->last_millis_ = now; #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last); #endif /* ESPHOME_DEBUG_SCHEDULER */ - } - - // Only update if time moved forward - if (now > last) { + } else if (now > last) { + // Only update if time moved forward this->last_millis_ = now; } From f19296ac7f59c4b2610b127cd1663e11648cf645 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Nov 2025 07:35:31 -0600 Subject: [PATCH 15/23] [analyze-memory] Show all core symbols > 100 B instead of top 15 (#11909) --- esphome/analyze_memory/cli.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index 718f42330d..44ade221f8 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -15,6 +15,11 @@ from . import ( class MemoryAnalyzerCLI(MemoryAnalyzer): """Memory analyzer with CLI-specific report generation.""" + # Symbol size threshold for detailed analysis + SYMBOL_SIZE_THRESHOLD: int = ( + 100 # Show symbols larger than this in detailed analysis + ) + # Column width constants COL_COMPONENT: int = 29 COL_FLASH_TEXT: int = 14 @@ -191,14 +196,21 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): f"{len(symbols):>{self.COL_CORE_COUNT}} | {percentage:>{self.COL_CORE_PERCENT - 1}.1f}%" ) - # Top 15 largest core symbols + # All core symbols above threshold lines.append("") - lines.append(f"Top 15 Largest {_COMPONENT_CORE} Symbols:") sorted_core_symbols = sorted( self._esphome_core_symbols, key=lambda x: x[2], reverse=True ) + large_core_symbols = [ + (symbol, demangled, size) + for symbol, demangled, size in sorted_core_symbols + if size > self.SYMBOL_SIZE_THRESHOLD + ] - for i, (symbol, demangled, size) in enumerate(sorted_core_symbols[:15]): + lines.append( + f"{_COMPONENT_CORE} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_core_symbols)} symbols):" + ) + for i, (symbol, demangled, size) in enumerate(large_core_symbols): lines.append(f"{i + 1}. {demangled} ({size:,} B)") lines.append("=" * self.TABLE_WIDTH) @@ -268,13 +280,15 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append(f"Total size: {comp_mem.flash_total:,} B") lines.append("") - # Show all symbols > 100 bytes for better visibility + # Show all symbols above threshold for better visibility large_symbols = [ - (sym, dem, size) for sym, dem, size in sorted_symbols if size > 100 + (sym, dem, size) + for sym, dem, size in sorted_symbols + if size > self.SYMBOL_SIZE_THRESHOLD ] lines.append( - f"{comp_name} Symbols > 100 B ({len(large_symbols)} symbols):" + f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_symbols)} symbols):" ) for i, (symbol, demangled, size) in enumerate(large_symbols): lines.append(f"{i + 1}. {demangled} ({size:,} B)") From 91514894817fa318186b29dec48af397f9222ca9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Nov 2025 18:38:38 -0600 Subject: [PATCH 16/23] [sntp] Merge multiple instances to fix crash and undefined behavior (#11904) --- esphome/components/sntp/time.py | 68 +++++ tests/component_tests/sntp/__init__.py | 1 + .../sntp/config/sntp_test.yaml | 22 ++ tests/component_tests/sntp/test_init.py | 238 ++++++++++++++++++ 4 files changed, 329 insertions(+) create mode 100644 tests/component_tests/sntp/__init__.py create mode 100644 tests/component_tests/sntp/config/sntp_test.yaml create mode 100644 tests/component_tests/sntp/test_init.py diff --git a/esphome/components/sntp/time.py b/esphome/components/sntp/time.py index d27fc9991d..69a2436d3d 100644 --- a/esphome/components/sntp/time.py +++ b/esphome/components/sntp/time.py @@ -1,9 +1,14 @@ +import logging + import esphome.codegen as cg from esphome.components import time as time_ +from esphome.config_helpers import merge_config import esphome.config_validation as cv from esphome.const import ( CONF_ID, + CONF_PLATFORM, CONF_SERVERS, + CONF_TIME, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -12,13 +17,74 @@ from esphome.const import ( PLATFORM_RTL87XX, ) from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["network"] + +CONF_SNTP = "sntp" + sntp_ns = cg.esphome_ns.namespace("sntp") SNTPComponent = sntp_ns.class_("SNTPComponent", time_.RealTimeClock) DEFAULT_SERVERS = ["0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org"] + +def _sntp_final_validate(config: ConfigType) -> None: + """Merge multiple SNTP instances into one, similar to OTA merging behavior.""" + full_conf = fv.full_config.get() + time_confs = full_conf.get(CONF_TIME, []) + + sntp_configs: list[ConfigType] = [] + other_time_configs: list[ConfigType] = [] + + for time_conf in time_confs: + if time_conf.get(CONF_PLATFORM) == CONF_SNTP: + sntp_configs.append(time_conf) + else: + other_time_configs.append(time_conf) + + if len(sntp_configs) <= 1: + return + + # Merge all SNTP configs into the first one + merged = sntp_configs[0] + for sntp_conf in sntp_configs[1:]: + # Validate that IDs are consistent if manually specified + if merged[CONF_ID].is_manual and sntp_conf[CONF_ID].is_manual: + raise cv.Invalid( + f"Found multiple SNTP configurations but {CONF_ID} is inconsistent" + ) + merged = merge_config(merged, sntp_conf) + + # Deduplicate servers while preserving order + servers = merged[CONF_SERVERS] + unique_servers = list(dict.fromkeys(servers)) + + # Warn if we're dropping servers due to 3-server limit + if len(unique_servers) > 3: + dropped = unique_servers[3:] + unique_servers = unique_servers[:3] + _LOGGER.warning( + "SNTP supports maximum 3 servers. Dropped excess server(s): %s", + dropped, + ) + + merged[CONF_SERVERS] = unique_servers + + _LOGGER.warning( + "Found and merged %d SNTP time configurations into one instance", + len(sntp_configs), + ) + + # Replace time configs with merged SNTP + other time platforms + other_time_configs.append(merged) + full_conf[CONF_TIME] = other_time_configs + fv.full_config.set(full_conf) + + CONFIG_SCHEMA = cv.All( time_.TIME_SCHEMA.extend( { @@ -40,6 +106,8 @@ CONFIG_SCHEMA = cv.All( ), ) +FINAL_VALIDATE_SCHEMA = _sntp_final_validate + async def to_code(config): servers = config[CONF_SERVERS] diff --git a/tests/component_tests/sntp/__init__.py b/tests/component_tests/sntp/__init__.py new file mode 100644 index 0000000000..7d323a4980 --- /dev/null +++ b/tests/component_tests/sntp/__init__.py @@ -0,0 +1 @@ +"""Tests for SNTP component.""" diff --git a/tests/component_tests/sntp/config/sntp_test.yaml b/tests/component_tests/sntp/config/sntp_test.yaml new file mode 100644 index 0000000000..3942c9606b --- /dev/null +++ b/tests/component_tests/sntp/config/sntp_test.yaml @@ -0,0 +1,22 @@ +esphome: + name: sntp-test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "testssid" + password: "testpassword" + +# Test multiple SNTP instances that should be merged +time: + - platform: sntp + servers: + - 192.168.1.1 + - pool.ntp.org + - platform: sntp + servers: + - pool.ntp.org + - 192.168.1.2 diff --git a/tests/component_tests/sntp/test_init.py b/tests/component_tests/sntp/test_init.py new file mode 100644 index 0000000000..9197ff55d0 --- /dev/null +++ b/tests/component_tests/sntp/test_init.py @@ -0,0 +1,238 @@ +"""Tests for SNTP time configuration validation.""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sntp.time import CONF_SNTP, _sntp_final_validate +from esphome.const import CONF_ID, CONF_PLATFORM, CONF_SERVERS, CONF_TIME +from esphome.core import ID +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + ("time_configs", "expected_count", "expected_servers", "warning_messages"), + [ + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + } + ], + 1, + ["192.168.1.1", "pool.ntp.org"], + [], + id="single_instance_no_merge", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="two_instances_merged", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="deduplication_preserves_order", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2", "pool2.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_3", is_manual=False), + CONF_SERVERS: ["pool3.ntp.org"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + [ + "SNTP supports maximum 3 servers. Dropped excess server(s): ['pool2.ntp.org', 'pool3.ntp.org']", + "Found and merged 3 SNTP time configurations into one instance", + ], + id="three_instances_drops_excess_servers", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: [ + "192.168.1.1", + "pool.ntp.org", + "pool.ntp.org", + "192.168.1.1", + ], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="deduplication_multiple_duplicates", + ), + ], +) +def test_sntp_instance_merging( + time_configs: list[dict[str, Any]], + expected_count: int, + expected_servers: list[str], + warning_messages: list[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test SNTP instance merging behavior.""" + # Create a mock full config with time configs + full_conf = {CONF_TIME: time_configs.copy()} + + # Set the context var + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _sntp_final_validate({}) + + # Get the updated config + updated_conf = fv.full_config.get() + + # Check if merging occurred + if len(time_configs) > 1: + # Verify only one SNTP instance remains + sntp_instances = [ + tc + for tc in updated_conf[CONF_TIME] + if tc.get(CONF_PLATFORM) == CONF_SNTP + ] + assert len(sntp_instances) == expected_count + + # Verify server list + assert sntp_instances[0][CONF_SERVERS] == expected_servers + + # Verify warnings + for expected_msg in warning_messages: + assert any( + expected_msg in record.message for record in caplog.records + ), f"Expected warning message '{expected_msg}' not found in log" + else: + # Single instance should not trigger merging or warnings + assert len(caplog.records) == 0 + # Config should be unchanged + assert updated_conf[CONF_TIME] == time_configs + finally: + fv.full_config.reset(token) + + +def test_sntp_inconsistent_manual_ids() -> None: + """Test that inconsistent manual IDs raise an error.""" + # Create configs with manual IDs that are inconsistent + time_configs = [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=True), + CONF_SERVERS: ["192.168.1.1"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=True), + CONF_SERVERS: ["192.168.1.2"], + }, + ] + + full_conf = {CONF_TIME: time_configs} + + token = fv.full_config.set(full_conf) + try: + with pytest.raises( + cv.Invalid, + match="Found multiple SNTP configurations but id is inconsistent", + ): + _sntp_final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> None: + """Test that SNTP merging doesn't affect other time platforms.""" + time_configs = [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1"], + }, + { + CONF_PLATFORM: "homeassistant", + CONF_ID: ID("homeassistant_time", is_manual=False), + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2"], + }, + ] + + full_conf = {CONF_TIME: time_configs.copy()} + + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _sntp_final_validate({}) + + updated_conf = fv.full_config.get() + + # Should have 2 time platforms: 1 merged SNTP + 1 homeassistant + assert len(updated_conf[CONF_TIME]) == 2 + + # Find the platforms + platforms = {tc[CONF_PLATFORM] for tc in updated_conf[CONF_TIME]} + assert platforms == {CONF_SNTP, "homeassistant"} + + # Verify SNTP was merged + sntp_instances = [ + tc for tc in updated_conf[CONF_TIME] if tc[CONF_PLATFORM] == CONF_SNTP + ] + assert len(sntp_instances) == 1 + assert sntp_instances[0][CONF_SERVERS] == ["192.168.1.1", "192.168.1.2"] + finally: + fv.full_config.reset(token) From 3fd58f1a917f3d69af101a544f2688d70ec69228 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Nov 2025 18:38:52 -0600 Subject: [PATCH 17/23] [web_server.ota] Merge multiple instances to prevent undefined behavior (#11905) --- esphome/components/web_server/ota/__init__.py | 58 ++++++- .../ota/test_web_server_ota.py | 153 ++++++++++++++++++ 2 files changed, 210 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server/ota/__init__.py b/esphome/components/web_server/ota/__init__.py index 4a98db8877..260e6aea6d 100644 --- a/esphome/components/web_server/ota/__init__.py +++ b/esphome/components/web_server/ota/__init__.py @@ -1,10 +1,17 @@ +import logging + import esphome.codegen as cg from esphome.components.esp32 import add_idf_component from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code +from esphome.config_helpers import merge_config import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_OTA, CONF_PLATFORM, CONF_WEB_SERVER from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network", "web_server_base"] @@ -12,6 +19,53 @@ DEPENDENCIES = ["network", "web_server_base"] web_server_ns = cg.esphome_ns.namespace("web_server") WebServerOTAComponent = web_server_ns.class_("WebServerOTAComponent", OTAComponent) + +def _web_server_ota_final_validate(config: ConfigType) -> None: + """Merge multiple web_server OTA instances into one. + + Multiple web_server OTA instances register duplicate HTTP handlers for /update, + causing undefined behavior. Merge them into a single instance. + """ + full_conf = fv.full_config.get() + ota_confs = full_conf.get(CONF_OTA, []) + + web_server_ota_configs: list[ConfigType] = [] + other_ota_configs: list[ConfigType] = [] + + for ota_conf in ota_confs: + if ota_conf.get(CONF_PLATFORM) == CONF_WEB_SERVER: + web_server_ota_configs.append(ota_conf) + else: + other_ota_configs.append(ota_conf) + + if len(web_server_ota_configs) <= 1: + return + + # Merge all web_server OTA configs into the first one + merged = web_server_ota_configs[0] + for ota_conf in web_server_ota_configs[1:]: + # Validate that IDs are consistent if manually specified + if ( + merged[CONF_ID].is_manual + and ota_conf[CONF_ID].is_manual + and merged[CONF_ID] != ota_conf[CONF_ID] + ): + raise cv.Invalid( + f"Found multiple web_server OTA configurations but {CONF_ID} is inconsistent" + ) + merged = merge_config(merged, ota_conf) + + _LOGGER.warning( + "Found and merged %d web_server OTA configurations into one instance", + len(web_server_ota_configs), + ) + + # Replace OTA configs with merged web_server + other OTA platforms + other_ota_configs.append(merged) + full_conf[CONF_OTA] = other_ota_configs + fv.full_config.set(full_conf) + + CONFIG_SCHEMA = ( cv.Schema( { @@ -22,6 +76,8 @@ CONFIG_SCHEMA = ( .extend(cv.COMPONENT_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = _web_server_ota_final_validate + @coroutine_with_priority(CoroPriority.WEB_SERVER_OTA) async def to_code(config): diff --git a/tests/component_tests/ota/test_web_server_ota.py b/tests/component_tests/ota/test_web_server_ota.py index 0d8ff6f134..794eaac9be 100644 --- a/tests/component_tests/ota/test_web_server_ota.py +++ b/tests/component_tests/ota/test_web_server_ota.py @@ -1,6 +1,18 @@ """Tests for the web_server OTA platform.""" +from __future__ import annotations + from collections.abc import Callable +import logging +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.web_server.ota import _web_server_ota_final_validate +from esphome.const import CONF_ID, CONF_OTA, CONF_PLATFORM, CONF_WEB_SERVER +from esphome.core import ID +import esphome.final_validate as fv def test_web_server_ota_generated(generate_main: Callable[[str], str]) -> None: @@ -100,3 +112,144 @@ def test_web_server_ota_esp8266(generate_main: Callable[[str], str]) -> None: # Check web server OTA component is present assert "WebServerOTAComponent" in main_cpp assert "web_server::WebServerOTAComponent" in main_cpp + + +@pytest.mark.parametrize( + ("ota_configs", "expected_count", "warning_expected"), + [ + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web", is_manual=False), + } + ], + 1, + False, + id="single_instance_no_merge", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=False), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=False), + }, + ], + 1, + True, + id="two_instances_merged", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=False), + }, + { + CONF_PLATFORM: "esphome", + CONF_ID: ID("ota_esphome", is_manual=False), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=False), + }, + ], + 2, + True, + id="mixed_platforms_web_server_merged", + ), + ], +) +def test_web_server_ota_instance_merging( + ota_configs: list[dict[str, Any]], + expected_count: int, + warning_expected: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test web_server OTA instance merging behavior.""" + full_conf = {CONF_OTA: ota_configs.copy()} + + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _web_server_ota_final_validate({}) + + updated_conf = fv.full_config.get() + + # Verify total number of OTA platforms + assert len(updated_conf[CONF_OTA]) == expected_count + + # Verify warning + if warning_expected: + assert any( + "Found and merged" in record.message + and "web_server OTA" in record.message + for record in caplog.records + ), "Expected merge warning not found in log" + else: + assert len(caplog.records) == 0, "Unexpected warnings logged" + finally: + fv.full_config.reset(token) + + +def test_web_server_ota_consistent_manual_ids( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that consistent manual IDs can be merged successfully.""" + ota_configs = [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web", is_manual=True), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web", is_manual=True), + }, + ] + + full_conf = {CONF_OTA: ota_configs} + + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _web_server_ota_final_validate({}) + + updated_conf = fv.full_config.get() + assert len(updated_conf[CONF_OTA]) == 1 + assert updated_conf[CONF_OTA][0][CONF_ID].id == "ota_web" + assert any( + "Found and merged" in record.message and "web_server OTA" in record.message + for record in caplog.records + ) + finally: + fv.full_config.reset(token) + + +def test_web_server_ota_inconsistent_manual_ids() -> None: + """Test that inconsistent manual IDs raise an error.""" + ota_configs = [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=True), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=True), + }, + ] + + full_conf = {CONF_OTA: ota_configs} + + token = fv.full_config.set(full_conf) + try: + with pytest.raises( + cv.Invalid, + match="Found multiple web_server OTA configurations but id is inconsistent", + ): + _web_server_ota_final_validate({}) + finally: + fv.full_config.reset(token) From 9e02e3191798010befd85006f17ebebc4a03b360 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Nov 2025 18:39:01 -0600 Subject: [PATCH 18/23] [web_server_idf] Fix lwIP assertion crash by shutting down sockets on connection close (#11937) --- .../components/web_server_idf/web_server_idf.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 0dab5e7e8c..ce91569de2 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -489,10 +489,18 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * void AsyncEventSourceResponse::destroy(void *ptr) { auto *rsp = static_cast(ptr); - ESP_LOGD(TAG, "Event source connection closed (fd: %d)", rsp->fd_.load()); - // Mark as dead by setting fd to 0 - will be cleaned up in the main loop - rsp->fd_.store(0); - // Note: We don't delete or remove from set here to avoid race conditions + int fd = rsp->fd_.exchange(0); // Atomically get and clear fd + + if (fd > 0) { + ESP_LOGD(TAG, "Event source connection closed (fd: %d)", fd); + // Immediately shut down the socket to prevent lwIP from delivering more data + // This prevents "recv_tcp: recv for wrong pcb!" assertions when the TCP stack + // tries to deliver queued data after the session is marked as dead + // See: https://github.com/esphome/esphome/issues/11936 + shutdown(fd, SHUT_RDWR); + // Note: We don't close() the socket - httpd owns it and will close it + } + // Session will be cleaned up in the main loop to avoid race conditions } // helper for allowing only unique entries in the queue From 6c6b03bda05a32519b0690e11b12706f1e2df24d Mon Sep 17 00:00:00 2001 From: Anton Sergunov Date: Mon, 17 Nov 2025 07:25:00 +0600 Subject: [PATCH 19/23] [uart] Setup uart pins only if flags are set (#11914) Co-authored-by: J. Nick Koston --- .../components/uart/uart_component_esp8266.cpp | 18 +++++++++++++----- .../components/uart/uart_component_esp_idf.cpp | 18 +++++++++++++----- .../uart/uart_component_libretiny.cpp | 2 +- .../components/uart/uart_component_rp2040.cpp | 18 +++++++++++++----- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 7a453dbb50..c84a877ef4 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -56,11 +56,19 @@ uint32_t ESP8266UartComponent::get_config() { } void ESP8266UartComponent::setup() { - if (this->rx_pin_) { - this->rx_pin_->setup(); - } - if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { - this->tx_pin_->setup(); + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); } // Use Arduino HardwareSerial UARTs if all used pins match the ones diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 70a13c9e37..61ca8c1c0c 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -133,11 +133,19 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } - if (this->rx_pin_) { - this->rx_pin_->setup(); - } - if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { - this->tx_pin_->setup(); + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); } int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 8d1d28fce4..1e408b169b 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -53,7 +53,7 @@ void LibreTinyUARTComponent::setup() { auto shouldFallbackToSoftwareSerial = [&]() -> bool { auto hasFlags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> bool { - return pin && pin->get_flags() & mask != gpio::Flags::FLAG_NONE; + return pin && (pin->get_flags() & mask) != gpio::Flags::FLAG_NONE; }; if (hasFlags(this->tx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN) || hasFlags(this->rx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) { diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2040.cpp index c78691653d..cd3905b5c1 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2040.cpp @@ -52,11 +52,19 @@ uint16_t RP2040UartComponent::get_config() { } void RP2040UartComponent::setup() { - if (this->rx_pin_) { - this->rx_pin_->setup(); - } - if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { - this->tx_pin_->setup(); + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); } uint16_t config = get_config(); From a38c4e0c6e4c4e4bf960db6f1e75f74afdfcb574 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:32:09 +1300 Subject: [PATCH 20/23] Bump version to 2025.11.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 8766b8f00c..04046d9ce6 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 = 2025.11.0b2 +PROJECT_NUMBER = 2025.11.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 8c6020a868..2ca0018fdb 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.11.0b2" +__version__ = "2025.11.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 10bdb47eae14bd3fd1cd761737b0633cd9c9894b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Nov 2025 20:37:06 -0600 Subject: [PATCH 21/23] [cover] Modernize to C++17 nested namespaces (#11935) --- esphome/components/cover/automation.h | 6 ++---- esphome/components/cover/cover.cpp | 6 ++---- esphome/components/cover/cover.h | 6 ++---- esphome/components/cover/cover_traits.h | 6 ++---- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index 752e0398c1..c0345a7cc6 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "cover.h" -namespace esphome { -namespace cover { +namespace esphome::cover { template class OpenAction : public Action { public: @@ -131,5 +130,4 @@ class CoverClosedTrigger : public Trigger<> { } }; -} // namespace cover -} // namespace esphome +} // namespace esphome::cover diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index 3062dba28a..8f735982f1 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -6,8 +6,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace cover { +namespace esphome::cover { static const char *const TAG = "cover"; @@ -212,5 +211,4 @@ void CoverRestoreState::apply(Cover *cover) { cover->publish_state(); } -} // namespace cover -} // namespace esphome +} // namespace esphome::cover diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index d5db6cfb4f..6c69c05e71 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -7,8 +7,7 @@ #include "cover_traits.h" -namespace esphome { -namespace cover { +namespace esphome::cover { const extern float COVER_OPEN; const extern float COVER_CLOSED; @@ -157,5 +156,4 @@ class Cover : public EntityBase, public EntityBase_DeviceClass { ESPPreferenceObject rtc_; }; -} // namespace cover -} // namespace esphome +} // namespace esphome::cover diff --git a/esphome/components/cover/cover_traits.h b/esphome/components/cover/cover_traits.h index 79001c3b03..723516318b 100644 --- a/esphome/components/cover/cover_traits.h +++ b/esphome/components/cover/cover_traits.h @@ -1,7 +1,6 @@ #pragma once -namespace esphome { -namespace cover { +namespace esphome::cover { class CoverTraits { public: @@ -26,5 +25,4 @@ class CoverTraits { bool supports_stop_{false}; }; -} // namespace cover -} // namespace esphome +} // namespace esphome::cover From 9e1f8d83f884b8fb9c168c1ea3909ef3fc365c21 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Mon, 17 Nov 2025 08:03:11 +0100 Subject: [PATCH 22/23] [config] Support !remove and !extend with LVGL-style configs (#11534) --- esphome/config.py | 59 +++++++++++-------- .../05-extend-remove.approved.yaml | 24 ++++++++ .../substitutions/05-extend-remove.input.yaml | 44 ++++++++++++++ 3 files changed, 104 insertions(+), 23 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index e508ca585b..4c8019de75 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -338,21 +338,44 @@ def check_replaceme(value): ) -def _build_list_index(lst): +def _get_item_id(item: Any) -> str | Extend | Remove | None: + """Attempts to get a list item's ID""" + if not isinstance(item, dict): + return None # not a dict, can't have ID + # 1.- Check regular case: + # - id: my_id + item_id = item.get(CONF_ID) + if item_id is None and len(item) == 1: + # 2.- Check single-key dict case: + # - obj: + # id: my_id + item = next(iter(item.values())) + if isinstance(item, dict): + item_id = item.get(CONF_ID) + if isinstance(item_id, Extend): + # Remove instances of Extend so they don't overwrite the original item when merging: + del item[CONF_ID] + return item_id + + +def _build_list_index( + lst: list[Any], +) -> tuple[ + OrderedDict[str | Extend | Remove, Any], list[tuple[int, str, Any]], set[str] +]: index = OrderedDict() extensions, removals = [], set() - for item in lst: + for pos, item in enumerate(lst): if item is None: removals.add(None) continue - item_id = None - if isinstance(item, dict) and (item_id := item.get(CONF_ID)): - if isinstance(item_id, Extend): - extensions.append(item) - continue - if isinstance(item_id, Remove): - removals.add(item_id.value) - continue + item_id = _get_item_id(item) + if isinstance(item_id, Extend): + extensions.append((pos, item_id.value, item)) + continue + if isinstance(item_id, Remove): + removals.add(item_id.value) + continue if not item_id or item_id in index: # no id or duplicate -> pass through with identity-based key item_id = id(item) @@ -360,7 +383,7 @@ def _build_list_index(lst): return index, extensions, removals -def resolve_extend_remove(value, is_key=None): +def resolve_extend_remove(value: Any, is_key: bool = False) -> None: if isinstance(value, ESPLiteralValue): return # do not check inside literal blocks if isinstance(value, list): @@ -368,26 +391,16 @@ def resolve_extend_remove(value, is_key=None): if extensions or removals: # Rebuild the original list after # processing all extensions and removals - for item in extensions: - item_id = item[CONF_ID].value + for pos, item_id, item in extensions: if item_id in removals: continue old = index.get(item_id) if old is None: # Failed to find source for extension - # Find index of item to show error at correct position - i = next( - ( - i - for i, d in enumerate(value) - if d.get(CONF_ID) == item[CONF_ID] - ) - ) - with cv.prepend_path(i): + with cv.prepend_path(pos): raise cv.Invalid( f"Source for extension of ID '{item_id}' was not found." ) - item[CONF_ID] = item_id index[item_id] = merge_config(old, item) for item_id in removals: index.pop(item_id, None) diff --git a/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml b/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml index a479370f4b..35e3e6258f 100644 --- a/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml +++ b/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml @@ -7,3 +7,27 @@ some_component: value: 2 - id: component2 value: 5 +lvgl: + pages: + - id: page1 + widgets: + - obj: + id: object1 + x: 3 + y: 2 + width: 4 + - obj: + id: object3 + x: 6 + y: 12 + widgets: + - obj: + id: object4 + x: 14 + y: 9 + width: 15 + height: 13 + - obj: + id: object5 + x: 10 + y: 11 diff --git a/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml b/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml index 2e0e60798d..617f09c31c 100644 --- a/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml +++ b/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml @@ -13,6 +13,30 @@ packages: value: 5 - id: component3 value: 6 + - lvgl: + pages: + - id: page1 + widgets: + - obj: + id: object1 + x: 1 + y: 2 + - obj: + id: object2 + x: 5 + - obj: + id: object3 + x: 6 + y: 7 + widgets: + - obj: + id: object4 + x: 8 + y: 9 + - obj: + id: object5 + x: 10 + y: 11 some_component: - id: !extend ${A} @@ -20,3 +44,23 @@ some_component: - id: component2 value: 3 - id: !remove ${C} + +lvgl: + pages: + - id: !extend page1 + widgets: + - obj: + id: !extend object1 + x: 3 + width: 4 + - obj: + id: !remove object2 + - obj: + id: !extend object3 + y: 12 + height: 13 + widgets: + - obj: + id: !extend object4 + x: 14 + width: 15 From 3d6c361037098f2a7990d1c41c0b4b50788afb64 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 17 Nov 2025 12:32:08 -0500 Subject: [PATCH 23/23] [core] Add support for setting environment variables (#11953) --- esphome/const.py | 1 + esphome/core/config.py | 15 +++++++++++++++ tests/components/esphome/common.yaml | 3 +++ 3 files changed, 19 insertions(+) diff --git a/esphome/const.py b/esphome/const.py index a25114d80e..b4cd3cfd1c 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -336,6 +336,7 @@ CONF_ENERGY = "energy" CONF_ENTITY_CATEGORY = "entity_category" CONF_ENTITY_ID = "entity_id" CONF_ENUM_DATAPOINT = "enum_datapoint" +CONF_ENVIRONMENT_VARIABLES = "environment_variables" CONF_EQUATION = "equation" CONF_ESP8266_DISABLE_SSL_SUPPORT = "esp8266_disable_ssl_support" CONF_ESPHOME = "esphome" diff --git a/esphome/core/config.py b/esphome/core/config.py index 763f9ebd9f..0a239c5f5e 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -17,6 +17,7 @@ from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_DEBUG_SCHEDULER, CONF_DEVICES, + CONF_ENVIRONMENT_VARIABLES, CONF_ESPHOME, CONF_FRIENDLY_NAME, CONF_ID, @@ -215,6 +216,11 @@ CONFIG_SCHEMA = cv.All( cv.string_strict: cv.Any([cv.string], cv.string), } ), + cv.Optional(CONF_ENVIRONMENT_VARIABLES, default={}): cv.Schema( + { + cv.string_strict: cv.string, + } + ), cv.Optional(CONF_ON_BOOT): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger), @@ -426,6 +432,12 @@ async def _add_platformio_options(pio_options): cg.add_platformio_option(key, val) +@coroutine_with_priority(CoroPriority.FINAL) +async def _add_environment_variables(env_vars: dict[str, str]) -> None: + # Set environment variables for the build process + os.environ.update(env_vars) + + @coroutine_with_priority(CoroPriority.AUTOMATION) async def _add_automations(config): for conf in config.get(CONF_ON_BOOT, []): @@ -563,6 +575,9 @@ async def to_code(config: ConfigType) -> None: if config[CONF_PLATFORMIO_OPTIONS]: CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS]) + if config[CONF_ENVIRONMENT_VARIABLES]: + CORE.add_job(_add_environment_variables, config[CONF_ENVIRONMENT_VARIABLES]) + # Process areas all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: diff --git a/tests/components/esphome/common.yaml b/tests/components/esphome/common.yaml index b2d7bccaa5..db75b08b38 100644 --- a/tests/components/esphome/common.yaml +++ b/tests/components/esphome/common.yaml @@ -2,6 +2,9 @@ esphome: debug_scheduler: true platformio_options: board_build.flash_mode: dio + environment_variables: + TEST_ENV_VAR: "test_value" + BUILD_NUMBER: "12345" area: id: testing_area name: Testing Area