1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-18 15:55:46 +00:00

Merge branch 'dev' into timeout_filter_scheduler_churn_fix

This commit is contained in:
J. Nick Koston
2025-11-17 15:12:59 -06:00
committed by GitHub
32 changed files with 974 additions and 264 deletions

View File

@@ -15,6 +15,11 @@ from . import (
class MemoryAnalyzerCLI(MemoryAnalyzer): class MemoryAnalyzerCLI(MemoryAnalyzer):
"""Memory analyzer with CLI-specific report generation.""" """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 # Column width constants
COL_COMPONENT: int = 29 COL_COMPONENT: int = 29
COL_FLASH_TEXT: int = 14 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}%" 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("")
lines.append(f"Top 15 Largest {_COMPONENT_CORE} Symbols:")
sorted_core_symbols = sorted( sorted_core_symbols = sorted(
self._esphome_core_symbols, key=lambda x: x[2], reverse=True 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(f"{i + 1}. {demangled} ({size:,} B)")
lines.append("=" * self.TABLE_WIDTH) lines.append("=" * self.TABLE_WIDTH)
@@ -268,13 +280,15 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(f"Total size: {comp_mem.flash_total:,} B") lines.append(f"Total size: {comp_mem.flash_total:,} B")
lines.append("") lines.append("")
# Show all symbols > 100 bytes for better visibility # Show all symbols above threshold for better visibility
large_symbols = [ 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( 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): for i, (symbol, demangled, size) in enumerate(large_symbols):
lines.append(f"{i + 1}. {demangled} ({size:,} B)") lines.append(f"{i + 1}. {demangled} ({size:,} B)")

View File

@@ -4,8 +4,7 @@
#include "esphome/core/automation.h" #include "esphome/core/automation.h"
#include "cover.h" #include "cover.h"
namespace esphome { namespace esphome::cover {
namespace cover {
template<typename... Ts> class OpenAction : public Action<Ts...> { template<typename... Ts> class OpenAction : public Action<Ts...> {
public: public:
@@ -131,5 +130,4 @@ class CoverClosedTrigger : public Trigger<> {
} }
}; };
} // namespace cover } // namespace esphome::cover
} // namespace esphome

View File

@@ -6,8 +6,7 @@
#include "esphome/core/log.h" #include "esphome/core/log.h"
namespace esphome { namespace esphome::cover {
namespace cover {
static const char *const TAG = "cover"; static const char *const TAG = "cover";
@@ -212,5 +211,4 @@ void CoverRestoreState::apply(Cover *cover) {
cover->publish_state(); cover->publish_state();
} }
} // namespace cover } // namespace esphome::cover
} // namespace esphome

View File

@@ -7,8 +7,7 @@
#include "cover_traits.h" #include "cover_traits.h"
namespace esphome { namespace esphome::cover {
namespace cover {
const extern float COVER_OPEN; const extern float COVER_OPEN;
const extern float COVER_CLOSED; const extern float COVER_CLOSED;
@@ -157,5 +156,4 @@ class Cover : public EntityBase, public EntityBase_DeviceClass {
ESPPreferenceObject rtc_; ESPPreferenceObject rtc_;
}; };
} // namespace cover } // namespace esphome::cover
} // namespace esphome

View File

@@ -1,7 +1,6 @@
#pragma once #pragma once
namespace esphome { namespace esphome::cover {
namespace cover {
class CoverTraits { class CoverTraits {
public: public:
@@ -26,5 +25,4 @@ class CoverTraits {
bool supports_stop_{false}; bool supports_stop_{false};
}; };
} // namespace cover } // namespace esphome::cover
} // namespace esphome

View File

@@ -486,6 +486,8 @@ class GlyphInfo:
def glyph_to_glyphinfo(glyph, font, size, bpp): def glyph_to_glyphinfo(glyph, font, size, bpp):
# Convert to 32 bit unicode codepoint
glyph = ord(glyph)
scale = 256 // (1 << bpp) scale = 256 // (1 << bpp)
if not font.is_scalable: if not font.is_scalable:
sizes = [pt_to_px(x.size) for x in font.available_sizes] sizes = [pt_to_px(x.size) for x in font.available_sizes]

View File

@@ -6,42 +6,147 @@
namespace esphome { namespace esphome {
namespace font { namespace font {
static const char *const TAG = "font"; static const char *const TAG = "font";
// Compare the char at the string position with this char. #ifdef USE_LVGL_FONT
// Return true if this char is less than or equal the other. const uint8_t *Font::get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter) {
bool Glyph::compare_to(const uint8_t *str) const { auto *fe = (Font *) font->dsc;
// 1 -> this->char_ const auto *gd = fe->get_glyph_data_(unicode_letter);
// 2 -> str if (gd == nullptr) {
for (uint32_t i = 0;; i++) { return nullptr;
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;
} }
// this should not happen return gd->data;
return false;
} }
int Glyph::match_length(const uint8_t *str) const {
for (uint32_t i = 0;; 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) {
if (this->a_char[i] == '\0') auto *fe = (Font *) font->dsc;
return i; const auto *gd = fe->get_glyph_data_(unicode_letter);
if (str[i] != this->a_char[i]) 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<const uint8_t *>(utf8_str);
uint32_t code_point = 0;
uint8_t c1 = *current++;
// check for end of string
if (c1 == 0) {
*length = 0;
return 0; return 0;
} }
// this should not happen
// --- 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; return 0;
} }
void Glyph::scan_area(int *x1, int *y1, int *width, int *height) const {
*x1 = this->offset_x; code_point = (c1 & 0x1F) << 6;
*y1 = this->offset_y; code_point |= (c2 & 0x3F);
*width = this->width;
*height = this->height; // Error Check 2: Overlong check (2-byte must be > 0x7F)
if (code_point <= 0x7F) {
*length = 0;
return 0;
}
}
// --- 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<const uint8_t *>(utf8_str);
return code_point;
} }
Font::Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, 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), linegap_(height - baseline - descender),
xheight_(xheight), xheight_(xheight),
capheight_(capheight), capheight_(capheight),
bpp_(bpp) {} bpp_(bpp) {
int Font::match_next_glyph(const uint8_t *str, int *match_length) const { #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 lo = 0;
int hi = this->glyphs_.size() - 1; int hi = this->glyphs_.size() - 1;
while (lo != hi) { while (lo != hi) {
int mid = (lo + hi + 1) / 2; int mid = (lo + hi + 1) / 2;
if (this->glyphs_[mid].compare_to(str)) { if (this->glyphs_[mid].is_less_or_equal(codepoint)) {
lo = mid; lo = mid;
} else { } else {
hi = mid - 1; hi = mid - 1;
} }
} }
*match_length = this->glyphs_[lo].match_length(str); auto *result = &this->glyphs_[lo];
if (*match_length <= 0) if (result->code_point == codepoint)
return -1; return result;
return lo; return nullptr;
} }
#ifdef USE_DISPLAY #ifdef USE_DISPLAY
void Font::measure(const char *str, int *width, int *x_offset, int *baseline, int *height) { void Font::measure(const char *str, int *width, int *x_offset, int *baseline, int *height) {
*baseline = this->baseline_; *baseline = this->baseline_;
*height = this->height_; *height = this->height_;
int i = 0;
int min_x = 0; int min_x = 0;
bool has_char = false; bool has_char = false;
int x = 0; int x = 0;
while (str[i] != '\0') { for (;;) {
int match_length; size_t length;
int glyph_n = this->match_next_glyph((const uint8_t *) str + i, &match_length); auto code_point = extract_unicode_codepoint(str, &length);
if (glyph_n < 0) { if (length == 0)
break;
str += length;
auto *glyph = this->find_glyph(code_point);
if (glyph == nullptr) {
// Unknown char, skip // Unknown char, skip
if (!this->get_glyphs().empty()) if (!this->glyphs_.empty())
x += this->get_glyphs()[0].advance; x += this->glyphs_[0].advance;
i++;
continue; continue;
} }
const Glyph &glyph = this->glyphs_[glyph_n];
if (!has_char) { if (!has_char) {
min_x = glyph.offset_x; min_x = glyph->offset_x;
} else { } 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; has_char = true;
} }
*x_offset = min_x; *x_offset = min_x;
*width = x - 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) { 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 x_at = x_start;
int scan_x1, scan_y1, scan_width, scan_height; for (;;) {
while (text[i] != '\0') { size_t length;
int match_length; auto code_point = extract_unicode_codepoint(text, &length);
int glyph_n = this->match_next_glyph((const uint8_t *) text + i, &match_length); if (length == 0)
if (glyph_n < 0) { break;
text += length;
auto *glyph = this->find_glyph(code_point);
if (glyph == nullptr) {
// Unknown char, skip // Unknown char, skip
ESP_LOGW(TAG, "Encountered character without representation in font: '%c'", text[i]); ESP_LOGW(TAG, "Codepoint 0x%08" PRIx32 " not found in font", code_point);
if (!this->get_glyphs().empty()) { if (!this->glyphs_.empty()) {
uint8_t glyph_width = this->get_glyphs()[0].advance; uint8_t glyph_width = this->glyphs_[0].advance;
display->filled_rectangle(x_at, y_start, glyph_width, this->height_, color); display->rectangle(x_at, y_start, glyph_width, this->height_, color);
x_at += glyph_width; x_at += glyph_width;
} }
i++;
continue; continue;
} }
const Glyph &glyph = this->get_glyphs()[glyph_n]; const uint8_t *data = glyph->data;
glyph.scan_area(&scan_x1, &scan_y1, &scan_width, &scan_height); const int max_x = x_at + glyph->offset_x + glyph->width;
const int max_y = y_start + glyph->offset_y + glyph->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;
uint8_t bitmask = 0; uint8_t bitmask = 0;
uint8_t pixel_data = 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_g = (float) background.g;
auto b_b = (float) background.b; auto b_b = (float) background.b;
auto b_w = (float) background.w; auto b_w = (float) background.w;
for (int glyph_y = y_start + scan_y1; glyph_y != max_y; glyph_y++) { for (int glyph_y = y_start + glyph->offset_y; glyph_y != max_y; glyph_y++) {
for (int glyph_x = x_at + scan_x1; glyph_x != max_x; glyph_x++) { for (int glyph_x = x_at + glyph->offset_x; glyph_x != max_x; glyph_x++) {
uint8_t pixel = 0; 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) { if (bitmask == 0) {
pixel_data = progmem_read_byte(data++); pixel_data = progmem_read_byte(data++);
bitmask = 0x80; bitmask = 0x80;
@@ -164,12 +280,9 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo
} }
} }
} }
x_at += glyph.advance; x_at += glyph->advance;
i += match_length;
} }
} }
#endif #endif
} // namespace font } // namespace font
} // namespace esphome } // namespace esphome

View File

@@ -6,6 +6,9 @@
#ifdef USE_DISPLAY #ifdef USE_DISPLAY
#include "esphome/components/display/display.h" #include "esphome/components/display/display.h"
#endif #endif
#ifdef USE_LVGL_FONT
#include <lvgl.h>
#endif
namespace esphome { namespace esphome {
namespace font { namespace font {
@@ -14,9 +17,9 @@ class Font;
class Glyph { class Glyph {
public: 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) int height)
: a_char(a_char), : code_point(code_point),
data(data), data(data),
advance(advance), advance(advance),
offset_x(offset_x), offset_x(offset_x),
@@ -24,24 +27,15 @@ class Glyph {
width(width), width(width),
height(height) {} height(height) {}
const uint8_t *get_char() const { return reinterpret_cast<const uint8_t *>(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; const uint32_t code_point;
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 uint8_t *data; const uint8_t *data;
int advance; int advance;
int offset_x; int offset_x;
int offset_y; int offset_y;
int width; int width;
int height; int height;
protected:
friend Font;
}; };
class 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, Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight,
uint8_t bpp = 1); 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 #ifdef USE_DISPLAY
void print(int x_start, int y_start, display::Display *display, Color color, const char *text, 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_xheight() { return this->xheight_; }
inline int get_capheight() { return this->capheight_; } inline int get_capheight() { return this->capheight_; }
inline int get_bpp() { return this->bpp_; } 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<Glyph> &get_glyphs() const { return glyphs_; } const ConstVector<Glyph> &get_glyphs() const { return glyphs_; }
@@ -91,6 +88,14 @@ class Font
int xheight_; int xheight_;
int capheight_; int capheight_;
uint8_t bpp_; // bits per pixel 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 } // namespace font

View File

@@ -52,15 +52,7 @@ from .schemas import (
from .styles import add_top_layer, styles_to_code, theme_to_code from .styles import add_top_layer, styles_to_code, theme_to_code
from .touchscreens import touchscreen_schema, touchscreens_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code
from .trigger import add_on_boot_triggers, generate_triggers from .trigger import add_on_boot_triggers, generate_triggers
from .types import ( from .types import IdleTrigger, PlainTrigger, lv_font_t, lv_group_t, lv_style_t, lvgl_ns
FontEngine,
IdleTrigger,
PlainTrigger,
lv_font_t,
lv_group_t,
lv_style_t,
lvgl_ns,
)
from .widgets import ( from .widgets import (
LvScrActType, LvScrActType,
Widget, Widget,
@@ -244,7 +236,6 @@ async def to_code(configs):
cg.add_global(lvgl_ns.using) cg.add_global(lvgl_ns.using)
for font in helpers.esphome_fonts_used: for font in helpers.esphome_fonts_used:
await cg.get_variable(font) 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] default_font = config_0[df.CONF_DEFAULT_FONT]
if not lvalid.is_lv_font(default_font): if not lvalid.is_lv_font(default_font):
add_define( add_define(
@@ -256,7 +247,8 @@ async def to_code(configs):
type=lv_font_t.operator("ptr").operator("const"), type=lv_font_t.operator("ptr").operator("const"),
) )
cg.new_variable( 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) add_define("LV_FONT_DEFAULT", df.DEFAULT_ESPHOME_FONT)
else: else:

View File

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

View File

@@ -493,6 +493,7 @@ class LvFont(LValidator):
return LV_FONTS return LV_FONTS
if is_lv_font(value): if is_lv_font(value):
return lv_builtin_font(value) return lv_builtin_font(value)
add_lv_use("font")
fontval = cv.use_id(Font)(value) fontval = cv.use_id(Font)(value)
esphome_fonts_used.add(fontval) esphome_fonts_used.add(fontval)
return requires_component("font")(fontval) return requires_component("font")(fontval)
@@ -502,7 +503,9 @@ class LvFont(LValidator):
async def process(self, value, args=()): async def process(self, value, args=()):
if is_lv_font(value): if is_lv_font(value):
return literal(f"&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() lv_font = LvFont()

View File

@@ -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; static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_332;
#endif // LV_COLOR_DEPTH #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 #ifdef USE_LVGL_IMAGE
// Shortcut / overload, so that the source of an image can easily be updated // Shortcut / overload, so that the source of an image can easily be updated
// from within a lambda. // from within a lambda.
@@ -134,24 +142,6 @@ template<typename... Ts> class ObjUpdateAction : public Action<Ts...> {
protected: protected:
std::function<void(Ts...)> lamb_; std::function<void(Ts...)> 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 #ifdef USE_LVGL_ANIMIMG
void lv_animimg_stop(lv_obj_t *obj); void lv_animimg_stop(lv_obj_t *obj);
#endif // USE_LVGL_ANIMIMG #endif // USE_LVGL_ANIMIMG

View File

@@ -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_event_code_t = cg.global_ns.enum("lv_event_code_t")
lv_indev_type_t = cg.global_ns.enum("lv_indev_type_t") lv_indev_type_t = cg.global_ns.enum("lv_indev_type_t")
lv_key_t = cg.global_ns.enum("lv_key_t") lv_key_t = cg.global_ns.enum("lv_key_t")
FontEngine = lvgl_ns.class_("FontEngine")
PlainTrigger = esphome_ns.class_("Trigger<>", automation.Trigger.template()) PlainTrigger = esphome_ns.class_("Trigger<>", automation.Trigger.template())
DrawEndTrigger = esphome_ns.class_( DrawEndTrigger = esphome_ns.class_(
"Trigger<uint32_t, uint32_t>", automation.Trigger.template(cg.uint32, cg.uint32) "Trigger<uint32_t, uint32_t>", automation.Trigger.template(cg.uint32, cg.uint32)

View File

@@ -1,9 +1,14 @@
import logging
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import time as time_ from esphome.components import time as time_
from esphome.config_helpers import merge_config
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_ID, CONF_ID,
CONF_PLATFORM,
CONF_SERVERS, CONF_SERVERS,
CONF_TIME,
PLATFORM_BK72XX, PLATFORM_BK72XX,
PLATFORM_ESP32, PLATFORM_ESP32,
PLATFORM_ESP8266, PLATFORM_ESP8266,
@@ -12,13 +17,74 @@ from esphome.const import (
PLATFORM_RTL87XX, PLATFORM_RTL87XX,
) )
from esphome.core import CORE from esphome.core import CORE
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
DEPENDENCIES = ["network"] DEPENDENCIES = ["network"]
CONF_SNTP = "sntp"
sntp_ns = cg.esphome_ns.namespace("sntp") sntp_ns = cg.esphome_ns.namespace("sntp")
SNTPComponent = sntp_ns.class_("SNTPComponent", time_.RealTimeClock) SNTPComponent = sntp_ns.class_("SNTPComponent", time_.RealTimeClock)
DEFAULT_SERVERS = ["0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org"] 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( CONFIG_SCHEMA = cv.All(
time_.TIME_SCHEMA.extend( time_.TIME_SCHEMA.extend(
{ {
@@ -40,6 +106,8 @@ CONFIG_SCHEMA = cv.All(
), ),
) )
FINAL_VALIDATE_SCHEMA = _sntp_final_validate
async def to_code(config): async def to_code(config):
servers = config[CONF_SERVERS] servers = config[CONF_SERVERS]

View File

@@ -56,11 +56,19 @@ uint32_t ESP8266UartComponent::get_config() {
} }
void ESP8266UartComponent::setup() { void ESP8266UartComponent::setup() {
if (this->rx_pin_) { auto setup_pin_if_needed = [](InternalGPIOPin *pin) {
this->rx_pin_->setup(); if (!pin) {
return;
} }
if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN;
this->tx_pin_->setup(); 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 // Use Arduino HardwareSerial UARTs if all used pins match the ones

View File

@@ -133,11 +133,19 @@ void IDFUARTComponent::load_settings(bool dump_config) {
return; return;
} }
if (this->rx_pin_) { auto setup_pin_if_needed = [](InternalGPIOPin *pin) {
this->rx_pin_->setup(); if (!pin) {
return;
} }
if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN;
this->tx_pin_->setup(); 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; int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1;

View File

@@ -53,7 +53,7 @@ void LibreTinyUARTComponent::setup() {
auto shouldFallbackToSoftwareSerial = [&]() -> bool { auto shouldFallbackToSoftwareSerial = [&]() -> bool {
auto hasFlags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> 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) || 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)) { hasFlags(this->rx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) {

View File

@@ -52,11 +52,19 @@ uint16_t RP2040UartComponent::get_config() {
} }
void RP2040UartComponent::setup() { void RP2040UartComponent::setup() {
if (this->rx_pin_) { auto setup_pin_if_needed = [](InternalGPIOPin *pin) {
this->rx_pin_->setup(); if (!pin) {
return;
} }
if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN;
this->tx_pin_->setup(); 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(); uint16_t config = get_config();

View File

@@ -1,10 +1,17 @@
import logging
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components.esp32 import add_idf_component from esphome.components.esp32 import add_idf_component
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code 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 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.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority from esphome.coroutine import CoroPriority
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@esphome/core"] CODEOWNERS = ["@esphome/core"]
DEPENDENCIES = ["network", "web_server_base"] DEPENDENCIES = ["network", "web_server_base"]
@@ -12,6 +19,53 @@ DEPENDENCIES = ["network", "web_server_base"]
web_server_ns = cg.esphome_ns.namespace("web_server") web_server_ns = cg.esphome_ns.namespace("web_server")
WebServerOTAComponent = web_server_ns.class_("WebServerOTAComponent", OTAComponent) 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 = ( CONFIG_SCHEMA = (
cv.Schema( cv.Schema(
{ {
@@ -22,6 +76,8 @@ CONFIG_SCHEMA = (
.extend(cv.COMPONENT_SCHEMA) .extend(cv.COMPONENT_SCHEMA)
) )
FINAL_VALIDATE_SCHEMA = _web_server_ota_final_validate
@coroutine_with_priority(CoroPriority.WEB_SERVER_OTA) @coroutine_with_priority(CoroPriority.WEB_SERVER_OTA)
async def to_code(config): async def to_code(config):

View File

@@ -489,10 +489,18 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest *
void AsyncEventSourceResponse::destroy(void *ptr) { void AsyncEventSourceResponse::destroy(void *ptr) {
auto *rsp = static_cast<AsyncEventSourceResponse *>(ptr); auto *rsp = static_cast<AsyncEventSourceResponse *>(ptr);
ESP_LOGD(TAG, "Event source connection closed (fd: %d)", rsp->fd_.load()); int fd = rsp->fd_.exchange(0); // Atomically get and clear fd
// Mark as dead by setting fd to 0 - will be cleaned up in the main loop
rsp->fd_.store(0); if (fd > 0) {
// Note: We don't delete or remove from set here to avoid race conditions 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 // helper for allowing only unique entries in the queue

View File

@@ -338,17 +338,40 @@ 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() index = OrderedDict()
extensions, removals = [], set() extensions, removals = [], set()
for item in lst: for pos, item in enumerate(lst):
if item is None: if item is None:
removals.add(None) removals.add(None)
continue continue
item_id = None item_id = _get_item_id(item)
if isinstance(item, dict) and (item_id := item.get(CONF_ID)):
if isinstance(item_id, Extend): if isinstance(item_id, Extend):
extensions.append(item) extensions.append((pos, item_id.value, item))
continue continue
if isinstance(item_id, Remove): if isinstance(item_id, Remove):
removals.add(item_id.value) removals.add(item_id.value)
@@ -360,7 +383,7 @@ def _build_list_index(lst):
return index, extensions, removals 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): if isinstance(value, ESPLiteralValue):
return # do not check inside literal blocks return # do not check inside literal blocks
if isinstance(value, list): if isinstance(value, list):
@@ -368,26 +391,16 @@ def resolve_extend_remove(value, is_key=None):
if extensions or removals: if extensions or removals:
# Rebuild the original list after # Rebuild the original list after
# processing all extensions and removals # processing all extensions and removals
for item in extensions: for pos, item_id, item in extensions:
item_id = item[CONF_ID].value
if item_id in removals: if item_id in removals:
continue continue
old = index.get(item_id) old = index.get(item_id)
if old is None: if old is None:
# Failed to find source for extension # Failed to find source for extension
# Find index of item to show error at correct position with cv.prepend_path(pos):
i = next(
(
i
for i, d in enumerate(value)
if d.get(CONF_ID) == item[CONF_ID]
)
)
with cv.prepend_path(i):
raise cv.Invalid( raise cv.Invalid(
f"Source for extension of ID '{item_id}' was not found." f"Source for extension of ID '{item_id}' was not found."
) )
item[CONF_ID] = item_id
index[item_id] = merge_config(old, item) index[item_id] = merge_config(old, item)
for item_id in removals: for item_id in removals:
index.pop(item_id, None) index.pop(item_id, None)

View File

@@ -336,6 +336,7 @@ CONF_ENERGY = "energy"
CONF_ENTITY_CATEGORY = "entity_category" CONF_ENTITY_CATEGORY = "entity_category"
CONF_ENTITY_ID = "entity_id" CONF_ENTITY_ID = "entity_id"
CONF_ENUM_DATAPOINT = "enum_datapoint" CONF_ENUM_DATAPOINT = "enum_datapoint"
CONF_ENVIRONMENT_VARIABLES = "environment_variables"
CONF_EQUATION = "equation" CONF_EQUATION = "equation"
CONF_ESP8266_DISABLE_SSL_SUPPORT = "esp8266_disable_ssl_support" CONF_ESP8266_DISABLE_SSL_SUPPORT = "esp8266_disable_ssl_support"
CONF_ESPHOME = "esphome" CONF_ESPHOME = "esphome"

View File

@@ -17,6 +17,7 @@ from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT, CONF_COMPILE_PROCESS_LIMIT,
CONF_DEBUG_SCHEDULER, CONF_DEBUG_SCHEDULER,
CONF_DEVICES, CONF_DEVICES,
CONF_ENVIRONMENT_VARIABLES,
CONF_ESPHOME, CONF_ESPHOME,
CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME,
CONF_ID, CONF_ID,
@@ -215,6 +216,11 @@ CONFIG_SCHEMA = cv.All(
cv.string_strict: cv.Any([cv.string], cv.string), 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.Optional(CONF_ON_BOOT): automation.validate_automation(
{ {
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger), 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) 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) @coroutine_with_priority(CoroPriority.AUTOMATION)
async def _add_automations(config): async def _add_automations(config):
for conf in config.get(CONF_ON_BOOT, []): for conf in config.get(CONF_ON_BOOT, []):
@@ -563,6 +575,9 @@ async def to_code(config: ConfigType) -> None:
if config[CONF_PLATFORMIO_OPTIONS]: if config[CONF_PLATFORMIO_OPTIONS]:
CORE.add_job(_add_platformio_options, 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 # Process areas
all_areas: list[dict[str, str | core.ID]] = [] all_areas: list[dict[str, str | core.ID]] = []
if CONF_AREA in config: if CONF_AREA in config:

View File

@@ -1,6 +1,18 @@
"""Tests for the web_server OTA platform.""" """Tests for the web_server OTA platform."""
from __future__ import annotations
from collections.abc import Callable 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: 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 # Check web server OTA component is present
assert "WebServerOTAComponent" in main_cpp assert "WebServerOTAComponent" in main_cpp
assert "web_server::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)

View File

@@ -0,0 +1 @@
"""Tests for SNTP component."""

View File

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

View File

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

View File

@@ -2,6 +2,9 @@ esphome:
debug_scheduler: true debug_scheduler: true
platformio_options: platformio_options:
board_build.flash_mode: dio board_build.flash_mode: dio
environment_variables:
TEST_ENV_VAR: "test_value"
BUILD_NUMBER: "12345"
area: area:
id: testing_area id: testing_area
name: Testing Area name: Testing Area

View File

@@ -76,7 +76,7 @@ lvgl:
line_width: 8 line_width: 8
line_rounded: true line_rounded: true
- id: date_style - id: date_style
text_font: roboto10 text_font: !lambda return id(roboto10);
align: center align: center
text_color: !lambda return color_id2; text_color: !lambda return color_id2;
bg_opa: cover bg_opa: cover
@@ -267,7 +267,7 @@ lvgl:
snprintf(buf, sizeof(buf), "Setup: %d", 42); snprintf(buf, sizeof(buf), "Setup: %d", 42);
return std::string(buf); return std::string(buf);
align: top_mid align: top_mid
text_font: space16 text_font: !lambda return id(space16);
- label: - label:
id: chip_info_label id: chip_info_label
# Test complex setup lambda (real-world pattern) # Test complex setup lambda (real-world pattern)

View File

@@ -18,6 +18,7 @@ touchscreen:
lvgl: lvgl:
- id: lvgl_0 - id: lvgl_0
default_font: space16
displays: sdl0 displays: sdl0
- id: lvgl_1 - id: lvgl_1
displays: sdl1 displays: sdl1
@@ -39,3 +40,8 @@ lvgl:
text: Click ME text: Click ME
on_click: on_click:
logger.log: Clicked logger.log: Clicked
font:
- file: "gfonts://Roboto"
id: space16
bpp: 4

View File

@@ -7,3 +7,27 @@ some_component:
value: 2 value: 2
- id: component2 - id: component2
value: 5 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

View File

@@ -13,6 +13,30 @@ packages:
value: 5 value: 5
- id: component3 - id: component3
value: 6 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: some_component:
- id: !extend ${A} - id: !extend ${A}
@@ -20,3 +44,23 @@ some_component:
- id: component2 - id: component2
value: 3 value: 3
- id: !remove ${C} - 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