Merge remote-tracking branch 'upstream/templatable-value-specialize' into integration

This commit is contained in:
J. Nick Koston
2026-04-07 19:14:36 -10:00
49 changed files with 1072 additions and 161 deletions
+1 -1
View File
@@ -1083,7 +1083,7 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
# add the console decoration so the front-end can hide the secrets
if not args.show_secrets:
output = re.sub(
r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[5m\2\\033[6m", output
r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output
)
if not CORE.quiet:
safe_print(output)
+5 -3
View File
@@ -110,12 +110,14 @@ class EPaperBase : public Display,
this->fill(COLOR_ON);
}
protected:
int get_height_internal() override { return this->height_; };
int get_width_internal() override { return this->width_; };
int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; }
int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; }
void draw_pixel_at(int x, int y, Color color) override;
protected:
int get_height_internal() override { return this->height_; };
int get_width_internal() override { return this->width_; };
bool is_using_partial_update_() const { return this->full_update_every_ > 1; }
void process_state_();
const char *epaper_state_to_string_();
@@ -15,7 +15,11 @@ void EPaperMono::refresh_screen(bool partial) {
void EPaperMono::deep_sleep() {
ESP_LOGV(TAG, "Deep sleep");
this->command(0x10);
if (this->is_using_partial_update_()) {
this->cmd_data(0x10, {0x00}); // sleep in power on mode
} else {
this->cmd_data(0x10, {0x03}); // deep sleep
}
}
bool EPaperMono::reset() {
@@ -27,6 +31,14 @@ bool EPaperMono::reset() {
}
void EPaperMono::set_window() {
// if not using partial update, the display will go into deep sleep, so must rewrite entire
// buffer since the display RAM will not retain contents
if (!this->is_using_partial_update_()) {
this->x_low_ = 0;
this->x_high_ = this->width_;
this->y_low_ = 0;
this->y_high_ = this->height_;
}
// round x-coordinates to byte boundaries
this->x_low_ &= ~7;
this->x_high_ += 7;
+12 -4
View File
@@ -123,6 +123,8 @@ ETHERNET_TYPES = {
"DM9051": EthernetType.ETHERNET_TYPE_DM9051,
"LAN8670": EthernetType.ETHERNET_TYPE_LAN8670,
"ENC28J60": EthernetType.ETHERNET_TYPE_ENC28J60,
"W6100": EthernetType.ETHERNET_TYPE_W6100,
"W6300": EthernetType.ETHERNET_TYPE_W6300,
}
# PHY types that need compile-time defines for conditional compilation
@@ -140,6 +142,8 @@ _PHY_TYPE_TO_DEFINE = {
"DM9051": "USE_ETHERNET_DM9051",
"LAN8670": "USE_ETHERNET_LAN8670",
"ENC28J60": "USE_ETHERNET_ENC28J60",
"W6100": "USE_ETHERNET_W6100",
"W6300": "USE_ETHERNET_W6300",
}
@@ -170,12 +174,14 @@ _ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60"}
# ESP32-only SPI ethernet types (W5100 is RP2040-only, no ESP-IDF driver)
SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60"}
# RP2040-supported SPI ethernet types
RP2040_SPI_ETHERNET_TYPES = {"W5100", "W5500", "ENC28J60"}
# RP2040-supported ethernet types (SPI and PIO QSPI)
RP2040_ETHERNET_TYPES = {"W5100", "W5500", "W6100", "W6300", "ENC28J60"}
_RP2040_SPI_LIBRARIES = {
"W5100": "lwIP_w5100",
"W5500": "lwIP_w5500",
"ENC28J60": "lwIP_enc28j60",
"W6100": "lwIP_w6100",
"W6300": "lwIP_w6300",
}
SPI_ETHERNET_DEFAULT_POLLING_INTERVAL = TimePeriodMilliseconds(milliseconds=10)
@@ -328,9 +334,9 @@ def _validate(config):
f"{config[CONF_TYPE]} PHY requires RMII interface and is only supported "
f"on ESP32 classic and ESP32-P4, not {variant}"
)
elif CORE.is_rp2040 and config[CONF_TYPE] not in RP2040_SPI_ETHERNET_TYPES:
elif CORE.is_rp2040 and config[CONF_TYPE] not in RP2040_ETHERNET_TYPES:
raise cv.Invalid(
f"Only {', '.join(sorted(RP2040_SPI_ETHERNET_TYPES))} are supported on RP2040, "
f"Only {', '.join(sorted(RP2040_ETHERNET_TYPES))} are supported on RP2040, "
f"not {config[CONF_TYPE]}"
)
return config
@@ -427,6 +433,8 @@ CONFIG_SCHEMA = cv.All(
"OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])),
"DM9051": SPI_SCHEMA,
"ENC28J60": SPI_SCHEMA,
"W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])),
"W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])),
"LAN8670": RMII_SCHEMA,
},
upper=True,
@@ -30,6 +30,20 @@ extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void);
#include <W5500lwIP.h>
#elif defined(USE_ETHERNET_W5100)
#include <W5100lwIP.h>
#elif defined(USE_ETHERNET_W6100)
#include <W6100lwIP.h>
#elif defined(USE_ETHERNET_W6300)
#include <W6300lwIP.h>
// W6300 uses PIO QSPI, not Arduino SPI. The upstream Wiznet6300 class
// incorrectly returns needsSPI()=true, causing LwipIntfDev::begin() to
// call SPI.begin() which claims GPIOs that PIO QSPI needs.
// This wrapper hides needsSPI() with a version returning false.
class Wiznet6300NoSPI : public Wiznet6300 {
public:
using Wiznet6300::Wiznet6300;
constexpr bool needsSPI() const { return false; }
};
using Wiznet6300lwIPFixed = LwipIntfDev<Wiznet6300NoSPI>;
#elif defined(USE_ETHERNET_ENC28J60)
#include <ENC28J60lwIP.h>
#else
@@ -70,6 +84,8 @@ enum EthernetType : uint8_t {
ETHERNET_TYPE_DM9051,
ETHERNET_TYPE_LAN8670,
ETHERNET_TYPE_ENC28J60,
ETHERNET_TYPE_W6100,
ETHERNET_TYPE_W6300,
};
struct ManualIP {
@@ -232,6 +248,8 @@ class EthernetComponent final : public Component {
static constexpr uint32_t LINK_CHECK_INTERVAL = 500; // ms between link/IP polls
#if defined(USE_ETHERNET_W5100)
static constexpr uint32_t RESET_DELAY_MS = 150; // W5100S PLL lock time
#elif defined(USE_ETHERNET_W6300)
static constexpr uint32_t RESET_DELAY_MS = 100; // W6300 needs 100ms after hardware reset
#else
static constexpr uint32_t RESET_DELAY_MS = 10;
#endif
@@ -239,6 +257,10 @@ class EthernetComponent final : public Component {
Wiznet5500lwIP *eth_{nullptr};
#elif defined(USE_ETHERNET_W5100)
Wiznet5100lwIP *eth_{nullptr};
#elif defined(USE_ETHERNET_W6100)
Wiznet6100lwIP *eth_{nullptr};
#elif defined(USE_ETHERNET_W6300)
Wiznet6300lwIPFixed *eth_{nullptr};
#elif defined(USE_ETHERNET_ENC28J60)
ENC28J60lwIP *eth_{nullptr};
#else
@@ -18,9 +18,14 @@ static const char *const TAG = "ethernet";
void EthernetComponent::setup() {
// Configure SPI pins
#if !defined(USE_ETHERNET_W6300)
SPI.setRX(this->miso_pin_);
SPI.setTX(this->mosi_pin_);
SPI.setSCK(this->clk_pin_);
#endif
// W6300 uses PIO QSPI with hardcoded pins, not Arduino SPI.
// SPI pin config is skipped; Wiznet6300lwIPFixed (needsSPI()=false)
// prevents LwipIntfDev::begin() from calling SPI.begin().
// Toggle reset pin if configured
if (this->reset_pin_ >= 0) {
@@ -40,6 +45,10 @@ void EthernetComponent::setup() {
this->eth_ = new Wiznet5500lwIP(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT
#elif defined(USE_ETHERNET_W5100)
this->eth_ = new Wiznet5100lwIP(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT
#elif defined(USE_ETHERNET_W6100)
this->eth_ = new Wiznet6100lwIP(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT
#elif defined(USE_ETHERNET_W6300)
this->eth_ = new Wiznet6300lwIPFixed(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT
#elif defined(USE_ETHERNET_ENC28J60)
this->eth_ = new ENC28J60lwIP(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT
#endif
@@ -183,9 +192,23 @@ void EthernetComponent::dump_config() {
type_str = "W5500";
#elif defined(USE_ETHERNET_W5100)
type_str = "W5100";
#elif defined(USE_ETHERNET_W6100)
type_str = "W6100";
#elif defined(USE_ETHERNET_W6300)
type_str = "W6300";
#elif defined(USE_ETHERNET_ENC28J60)
type_str = "ENC28J60";
#endif
#if defined(USE_ETHERNET_W6300)
// W6300 uses PIO QSPI with hardcoded pins — SPI pin fields are not used
ESP_LOGCONFIG(TAG,
"Ethernet:\n"
" Type: %s (PIO QSPI)\n"
" Connected: %s\n"
" IRQ Pin: %d\n"
" Reset Pin: %d",
type_str, YESNO(this->is_connected()), this->interrupt_pin_, this->reset_pin_);
#else
ESP_LOGCONFIG(TAG,
"Ethernet:\n"
" Type: %s\n"
@@ -198,6 +221,7 @@ void EthernetComponent::dump_config() {
" Reset Pin: %d",
type_str, YESNO(this->is_connected()), this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_,
this->interrupt_pin_, this->reset_pin_);
#endif
this->dump_connect_params_();
}
+40 -20
View File
@@ -6,9 +6,15 @@ namespace esphome {
namespace gdk101 {
static const char *const TAG = "gdk101";
static const uint8_t NUMBER_OF_READ_RETRIES = 5;
static constexpr uint8_t NUMBER_OF_READ_RETRIES = 5;
static constexpr uint8_t NUMBER_OF_RESET_RETRIES = 10;
static constexpr uint32_t RESET_INTERVAL_ID = 0;
static constexpr uint32_t RESET_INTERVAL_MS = 1000;
void GDK101Component::update() {
if (!this->reset_complete_)
return;
uint8_t data[2];
if (!this->read_dose_1m_(data)) {
this->status_set_warning(LOG_STR("Failed to read dose 1m"));
@@ -33,26 +39,45 @@ void GDK101Component::update() {
}
void GDK101Component::setup() {
uint8_t data[2];
// first, reset the sensor
if (!this->try_reset_()) {
// Sensor MCU boots slowly after power cycle — retry on a short interval
this->reset_retries_remaining_ = NUMBER_OF_RESET_RETRIES;
this->set_interval(RESET_INTERVAL_ID, RESET_INTERVAL_MS, [this]() {
if (this->try_reset_()) {
if (this->reset_complete_) {
this->update();
}
return;
}
if (--this->reset_retries_remaining_ == 0) {
this->cancel_interval(RESET_INTERVAL_ID);
this->mark_failed(LOG_STR("Reset failed after retries"));
}
});
}
}
/// Attempt to reset the sensor and read firmware version. Returns true on success or hard failure.
bool GDK101Component::try_reset_() {
uint8_t data[2] = {0};
if (!this->reset_sensor_(data)) {
this->status_set_error(LOG_STR("Reset failed!"));
this->mark_failed();
return;
this->status_set_warning(LOG_STR("Sensor not answering reset, will retry"));
return false;
}
// sensor should acknowledge success of the reset procedure
if (data[0] != 1) {
this->status_set_error(LOG_STR("Reset not acknowledged!"));
this->mark_failed();
return;
this->status_set_warning(LOG_STR("Reset not acknowledged, will retry"));
return false;
}
delay(10);
// read firmware version
if (!this->read_fw_version_(data)) {
this->status_set_error(LOG_STR("Failed to read firmware version"));
this->mark_failed();
return;
this->cancel_interval(RESET_INTERVAL_ID);
this->mark_failed(LOG_STR("Failed to read firmware version"));
return true;
}
this->reset_complete_ = true;
this->status_clear_warning();
this->cancel_interval(RESET_INTERVAL_ID);
return true;
}
void GDK101Component::dump_config() {
@@ -92,12 +117,7 @@ bool GDK101Component::reset_sensor_(uint8_t *data) {
// After sending reset command it looks that sensor start performing reset and is unresponsible during read
// after a while we can send another reset command and read "0x01" as confirmation
// Documentation not going in to such details unfortunately
if (!this->read_bytes_with_retry_(GDK101_REG_RESET, data, 2)) {
ESP_LOGE(TAG, "Updating GDK101 failed!");
return false;
}
return true;
return this->read_bytes_with_retry_(GDK101_REG_RESET, data, 2);
}
bool GDK101Component::read_dose_1m_(uint8_t *data) {
+3
View File
@@ -44,12 +44,15 @@ class GDK101Component : public PollingComponent, public i2c::I2CDevice {
protected:
bool read_bytes_with_retry_(uint8_t a_register, uint8_t *data, uint8_t len);
bool try_reset_();
bool reset_sensor_(uint8_t *data);
bool read_dose_1m_(uint8_t *data);
bool read_dose_10m_(uint8_t *data);
bool read_status_(uint8_t *data);
bool read_fw_version_(uint8_t *data);
bool read_measurement_duration_(uint8_t *data);
bool reset_complete_{false};
uint8_t reset_retries_remaining_{0};
};
} // namespace gdk101
+12 -3
View File
@@ -9,6 +9,7 @@ from esphome.const import (
CONF_VALUE,
)
from esphome.core import CoroPriority, coroutine_with_priority
from esphome.cpp_generator import LambdaExpression
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
@@ -108,8 +109,16 @@ async def globals_set_to_code(config, action_id, template_arg, args):
full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID])
template_arg = cg.TemplateArguments(full_id.type, *template_arg)
var = cg.new_Pvariable(action_id, template_arg, paren)
templ = await cg.templatable(
config[CONF_VALUE], args, None, to_exp=cg.RawExpression
)
value = config[CONF_VALUE]
if cg.is_template(value):
templ = await cg.templatable(value, args, None, to_exp=cg.RawExpression)
else:
# Wrap raw constant in a stateless lambda for TemplatableFn storage.
# Use RawExpression for the value since T is a template parameter
# (the C++ compiler handles the type deduction).
raw_value = cg.RawExpression(value)
templ = LambdaExpression(
f"return {cg.safe_exp(raw_value)};", args, capture="", return_type=None
)
cg.add(var.set_value(templ))
return var
+2 -1
View File
@@ -307,7 +307,8 @@ async def http_request_action_to_code(config, action_id, template_arg, args):
capture_response = config[CONF_CAPTURE_RESPONSE]
if capture_response:
cg.add(var.set_capture_response(capture_response))
template_ = await cg.templatable(capture_response, args, cg.bool_)
cg.add(var.set_capture_response(template_))
cg.add_define("USE_HTTP_REQUEST_RESPONSE")
cg.add(var.set_max_response_buffer_size(config[CONF_MAX_RESPONSE_BUFFER_SIZE]))
+8 -5
View File
@@ -187,7 +187,6 @@ def final_validation(config_list):
for config in config_list:
if (pages := config.get(CONF_PAGES)) and all(p[df.CONF_SKIP] for p in pages):
raise cv.Invalid("At least one page must not be skipped")
uses_rotation = CONF_ROTATION in config
for display_id in config[df.CONF_DISPLAYS]:
path = global_config.get_path_for_id(display_id)[:-1]
display = global_config.get_config_for_path(path)
@@ -196,9 +195,9 @@ def final_validation(config_list):
"Using lambda: or pages: in display config is not compatible with LVGL"
)
# treating 0 as false is intended here.
if uses_rotation and display.get(CONF_ROTATION):
df.LOGGER.warning(
"use of 'rotation' in both LVGL and the display config is not recommended"
if display.get(CONF_ROTATION):
raise cv.Invalid(
"use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead"
)
if display.get(CONF_AUTO_CLEAR_ENABLED) is True:
raise cv.Invalid(
@@ -262,6 +261,7 @@ async def to_code(configs):
df.add_define("LV_USE_STDLIB_SPRINTF", "LV_STDLIB_CLIB")
df.add_define("LV_USE_STDLIB_STRING", "LV_STDLIB_CLIB")
df.add_define("LV_USE_STDLIB_MALLOC", "LV_STDLIB_CUSTOM")
df.add_define("LV_DEF_REFR_PERIOD", "16")
cg.add_define("USE_LVGL")
# suppress default enabling of extra widgets
# cg.add_define("LV_KCONFIG_PRESENT")
@@ -341,7 +341,10 @@ async def to_code(configs):
df.LOGGER.info("LVGL will use hardware rotation via display driver")
else:
rotation_type = RotationType.ROTATION_SOFTWARE
df.LOGGER.info("LVGL will use software rotation")
if get_esp32_variant() == VARIANT_ESP32P4:
df.LOGGER.info("LVGL will use software rotation (PPA accelerated)")
else:
df.LOGGER.info("LVGL will use software rotation")
lv_component = cg.new_Pvariable(
config[CONF_ID],
displays,
+124 -20
View File
@@ -158,8 +158,15 @@ void LvglComponent::dump_config() {
" Draw rounding: %d",
this->width_, this->height_, 100 / this->buffer_frac_, this->rotation_, (int) this->draw_rounding);
if (this->rotation_type_ != ROTATION_UNUSED) {
ESP_LOGCONFIG(TAG, " Rotation type: %s",
this->rotation_type_ == RotationType::ROTATION_SOFTWARE ? "software" : "hardware via display driver");
const char *rot_type = "hardware via display driver";
if (this->rotation_type_ == RotationType::ROTATION_SOFTWARE) {
#ifdef USE_ESP32_VARIANT_ESP32P4
rot_type = this->ppa_client_ != nullptr ? "software (PPA accelerated)" : "software";
#else
rot_type = "software";
#endif
}
ESP_LOGCONFIG(TAG, " Rotation type: %s", rot_type);
}
}
@@ -252,21 +259,120 @@ void LvglComponent::show_prev_page(lv_screen_load_anim_t anim, uint32_t time) {
size_t LvglComponent::get_current_page() const { return this->current_page_; }
bool LvPageType::is_showing() const { return this->parent_->get_current_page() == this->index; }
#ifdef USE_ESP32_VARIANT_ESP32P4
bool LvglComponent::ppa_rotate_(const lv_color_data *src, lv_color_data *dst, uint16_t width, uint16_t height,
uint32_t height_rounded) {
ppa_srm_rotation_angle_t angle;
uint16_t out_w, out_h;
// Map ESPHome clockwise display rotation to PPA counter-clockwise angles
switch (this->rotation_) {
case display::DISPLAY_ROTATION_90_DEGREES:
angle = PPA_SRM_ROTATION_ANGLE_270; // 270° CCW = 90° CW
out_w = height_rounded;
out_h = width;
break;
case display::DISPLAY_ROTATION_180_DEGREES:
angle = PPA_SRM_ROTATION_ANGLE_180;
out_w = width;
out_h = height;
break;
case display::DISPLAY_ROTATION_270_DEGREES:
angle = PPA_SRM_ROTATION_ANGLE_90; // 90° CCW = 270° CW
out_w = height_rounded;
out_h = width;
break;
default:
return false; // No rotation needed
}
// Align buffer size to cache line (LV_DRAW_BUF_ALIGN) as required by PPA DMA
// the underlying buffer will be large enough as the size is also padded when allocating.
size_t out_buf_size = out_w * out_h * sizeof(lv_color_data);
out_buf_size = LV_ROUND_UP(out_buf_size, LV_DRAW_BUF_ALIGN);
ppa_srm_oper_config_t srm_config{};
srm_config.in.buffer = src;
srm_config.in.pic_w = width;
srm_config.in.pic_h = height;
srm_config.in.block_w = width;
srm_config.in.block_h = height;
#if LV_COLOR_DEPTH == 16
srm_config.in.srm_cm = PPA_SRM_COLOR_MODE_RGB565;
#elif LV_COLOR_DEPTH == 32
srm_config.in.srm_cm = PPA_SRM_COLOR_MODE_ARGB8888;
#endif
srm_config.out.buffer = dst;
srm_config.out.buffer_size = out_buf_size;
srm_config.out.pic_w = out_w;
srm_config.out.pic_h = out_h;
#if LV_COLOR_DEPTH == 16
srm_config.out.srm_cm = PPA_SRM_COLOR_MODE_RGB565;
#elif LV_COLOR_DEPTH == 32
srm_config.out.srm_cm = PPA_SRM_COLOR_MODE_ARGB8888;
#endif
srm_config.rotation_angle = angle;
srm_config.scale_x = 1.0f;
srm_config.scale_y = 1.0f;
srm_config.mode = PPA_TRANS_MODE_BLOCKING;
esp_err_t ret = ppa_do_scale_rotate_mirror(this->ppa_client_, &srm_config);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "PPA rotation failed: %s", esp_err_to_name(ret));
ESP_LOGW(TAG, "PPA SRM: in=%ux%u src=%p, out=%ux%u dst=%p size=%zu, angle=%d", width, height, src, out_w, out_h,
dst, out_buf_size, (int) angle);
return false;
}
return true;
}
#endif // USE_ESP32_VARIANT_ESP32P4
void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) {
auto width = lv_area_get_width(area);
auto height = lv_area_get_height(area);
auto height_rounded = (height + this->draw_rounding - 1) / this->draw_rounding * this->draw_rounding;
auto x1 = area->x1;
auto y1 = area->y1;
if (this->rotation_type_ == RotationType::ROTATION_SOFTWARE) {
if (this->rotation_type_ == ROTATION_SOFTWARE) {
lv_color_data *dst = reinterpret_cast<lv_color_data *>(this->rotate_buf_);
#ifdef USE_ESP32_VARIANT_ESP32P4
bool ppa_done = this->ppa_client_ != nullptr && this->ppa_rotate_(ptr, dst, width, height, height_rounded);
if (!ppa_done)
#endif
{
switch (this->rotation_) {
case display::DISPLAY_ROTATION_90_DEGREES:
for (lv_coord_t x = height; x-- != 0;) {
for (lv_coord_t y = 0; y != width; y++) {
dst[y * height_rounded + x] = *ptr++;
}
}
break;
case display::DISPLAY_ROTATION_180_DEGREES:
for (lv_coord_t y = height; y-- != 0;) {
for (lv_coord_t x = width; x-- != 0;) {
dst[y * width + x] = *ptr++;
}
}
break;
case display::DISPLAY_ROTATION_270_DEGREES:
for (lv_coord_t x = 0; x != height; x++) {
for (lv_coord_t y = width; y-- != 0;) {
dst[y * height_rounded + x] = *ptr++;
}
}
break;
default:
dst = ptr;
break;
}
}
// Coordinate adjustments apply regardless of PPA or SW rotation
switch (this->rotation_) {
case display::DISPLAY_ROTATION_90_DEGREES:
for (lv_coord_t x = height; x-- != 0;) {
for (lv_coord_t y = 0; y != width; y++) {
dst[y * height_rounded + x] = *ptr++;
}
}
y1 = x1;
x1 = this->width_ - area->y1 - height;
height = width;
@@ -274,21 +380,11 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) {
break;
case display::DISPLAY_ROTATION_180_DEGREES:
for (lv_coord_t y = height; y-- != 0;) {
for (lv_coord_t x = width; x-- != 0;) {
dst[y * width + x] = *ptr++;
}
}
x1 = this->width_ - x1 - width;
y1 = this->height_ - y1 - height;
break;
case display::DISPLAY_ROTATION_270_DEGREES:
for (lv_coord_t x = 0; x != height; x++) {
for (lv_coord_t y = width; y-- != 0;) {
dst[y * height_rounded + x] = *ptr++;
}
}
x1 = y1;
y1 = this->height_ - area->x1 - width;
height = width;
@@ -296,7 +392,6 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) {
break;
default:
dst = ptr;
break;
}
ptr = dst;
@@ -664,6 +759,15 @@ void LvglComponent::setup() {
this->mark_failed();
return;
}
#ifdef USE_ESP32_VARIANT_ESP32P4
ppa_client_config_t ppa_config{};
ppa_config.oper_type = PPA_OPERATION_SRM;
ppa_config.max_pending_trans_num = 1;
if (ppa_register_client(&ppa_config, &this->ppa_client_) != ESP_OK) {
ESP_LOGW(TAG, "PPA client registration failed, using software rotation");
this->ppa_client_ = nullptr;
}
#endif
}
if (this->draw_start_callback_ != nullptr) {
lv_display_add_event_cb(this->disp_, render_start_cb, LV_EVENT_RENDER_START, this);
@@ -804,7 +908,7 @@ static unsigned cap_bits = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; // NOLINT
static void *lv_alloc_draw_buf(size_t size, bool internal) {
void *buffer;
size = ((size + LV_DRAW_BUF_ALIGN - 1) / LV_DRAW_BUF_ALIGN) * LV_DRAW_BUF_ALIGN;
size = LV_ROUND_UP(size, LV_DRAW_BUF_ALIGN);
buffer = heap_caps_aligned_alloc(LV_DRAW_BUF_ALIGN, size, internal ? MALLOC_CAP_8BIT : cap_bits); // NOLINT
if (buffer == nullptr)
ESP_LOGW(esphome::lvgl::TAG, "Failed to allocate %zu bytes for %sdraw buffer", size, internal ? "internal " : "");
+14
View File
@@ -26,6 +26,10 @@
#include <utility>
#include <vector>
#ifdef USE_ESP32_VARIANT_ESP32P4
#include "driver/ppa.h"
#endif
#ifdef USE_FONT
#include "esphome/components/font/font.h"
#endif // USE_LVGL_FONT
@@ -229,6 +233,9 @@ class LvglComponent : public PollingComponent {
display::DisplayRotation get_rotation() const { return this->rotation_; }
void rotate_coordinates(int32_t &x, int32_t &y) const;
uint16_t get_width() const { return lv_display_get_horizontal_resolution(this->disp_); }
uint16_t get_height() const { return lv_display_get_vertical_resolution(this->disp_); }
protected:
void set_resolution_() const;
void draw_end_();
@@ -238,6 +245,10 @@ class LvglComponent : public PollingComponent {
void write_random_();
void draw_buffer_(const lv_area_t *area, lv_color_data *ptr);
#ifdef USE_ESP32_VARIANT_ESP32P4
bool ppa_rotate_(const lv_color_data *src, lv_color_data *dst, uint16_t width, uint16_t height,
uint32_t height_rounded);
#endif
void flush_cb_(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p);
std::vector<display::Display *> displays_{};
@@ -266,6 +277,9 @@ class LvglComponent : public PollingComponent {
void *rotate_buf_{};
display::DisplayRotation rotation_{display::DISPLAY_ROTATION_0_DEGREES};
RotationType rotation_type_;
#ifdef USE_ESP32_VARIANT_ESP32P4
ppa_client_handle_t ppa_client_{};
#endif
};
class IdleTrigger : public Trigger<> {
+51 -10
View File
@@ -6,7 +6,12 @@ from pathlib import Path
from typing import Any
from esphome import git, yaml_util
from esphome.components.substitutions import ContextVars, push_context, substitute
from esphome.components.substitutions import (
ContextVars,
push_context,
resolve_include,
substitute,
)
from esphome.components.substitutions.jinja import has_jinja
from esphome.config_helpers import Remove, merge_config
import esphome.config_validation as cv
@@ -31,6 +36,8 @@ from esphome.core import EsphomeError
_LOGGER = logging.getLogger(__name__)
DOMAIN = CONF_PACKAGES
# Guard against infinite include chains (e.g. A includes B includes A).
MAX_INCLUDE_DEPTH = 20
def is_remote_package(package_config: dict) -> bool:
@@ -59,8 +66,8 @@ def valid_package_contents(package_config: dict) -> dict:
for k, v in package_config.items():
if not isinstance(k, str):
raise cv.Invalid("Package content keys must be strings")
if isinstance(v, (dict, list, Remove)):
continue # e.g. script: [], psram: !remove, logger: {level: debug}
if isinstance(v, (dict, list, Remove, yaml_util.IncludeFile)):
continue # e.g. script: [], psram: !remove, logger: {level: debug}, switch: !include switches.yaml
if v is None:
continue # e.g. web_server:
if isinstance(v, str) and has_jinja(v):
@@ -160,6 +167,7 @@ REMOTE_PACKAGE_SCHEMA = cv.All(
PACKAGE_SCHEMA = cv.Any( # A package definition is either:
validate_source_shorthand, # A git URL shorthand string that expands to a remote package schema, or
REMOTE_PACKAGE_SCHEMA, # a valid remote package schema, or
yaml_util.IncludeFile, # isinstance check — passes IncludeFile objects through unchanged, or:
valid_package_contents, # Something that at least looks like an actual package, e.g. {wifi:{ssid: xxx}}
# which will have to be fully validated later as per each component's schema.
)
@@ -396,16 +404,49 @@ class _PackageProcessor:
self.skip_update = skip_update
def resolve_package(
self, package_config: dict | str, context_vars: ContextVars | None
self,
package_config: dict | str | yaml_util.IncludeFile,
context_vars: ContextVars | None,
) -> dict:
"""Substitute variables in the definition and fetch remote packages.
"""Resolve a package definition to a concrete ``dict`` and fetch remote packages.
The input may be a ``str`` (git shorthand or Jinja expression) or a
``dict`` (remote or local package). After ``PACKAGE_SCHEMA`` validation
the result is always a ``dict``.
The input may be a ``str`` (git shorthand or Jinja expression), a
``dict`` (remote or local package), or an ``IncludeFile`` whose filename
may itself contain substitution expressions.
The loop handles the case where loading an ``IncludeFile`` yields another
``IncludeFile`` (e.g. a chain of deferred includes). Each iteration:
1. If the current value is an ``IncludeFile``, load it — resolving any
substitutions in its filename first.
2. Substitute variables in the resulting value (for strings and remote
package dicts).
3. Validate against ``PACKAGE_SCHEMA``. If the result is a ``dict``,
the loop exits; otherwise another iteration is needed.
Raises ``cv.Invalid`` if the chain has not resolved to a ``dict`` after
``MAX_INCLUDE_DEPTH`` iterations.
"""
package_config = _substitute_package_definition(package_config, context_vars)
package_config = PACKAGE_SCHEMA(package_config)
for _ in range(MAX_INCLUDE_DEPTH):
if isinstance(package_config, yaml_util.IncludeFile):
package_config, _ = resolve_include(
package_config,
[],
context_vars or ContextVars(),
strict_undefined=False,
)
package_config = _substitute_package_definition(
package_config, context_vars
)
package_config = PACKAGE_SCHEMA(package_config)
if isinstance(package_config, dict):
break
else:
raise cv.Invalid(
f"Maximum include nesting depth ({MAX_INCLUDE_DEPTH}) exceeded"
)
if is_remote_package(package_config):
package_config = _process_remote_package(package_config, self.skip_update)
return package_config
+8 -1
View File
@@ -2123,7 +2123,8 @@ async def abbwelcome_action(var, config, args):
await cg.templatable(config[CONF_MESSAGE_TYPE], args, cg.uint8)
)
)
cg.add(var.set_auto_message_id(CONF_MESSAGE_ID not in config))
template_ = await cg.templatable(CONF_MESSAGE_ID not in config, args, cg.bool_)
cg.add(var.set_auto_message_id(template_))
if CONF_MESSAGE_ID in config:
cg.add(
var.set_message_id(
@@ -2231,3 +2232,9 @@ async def Toto_action(var, config, args):
cg.add(var.set_rc_code_2(template_))
template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8)
cg.add(var.set_command(template_))
# Set toto-specific defaults (only if user didn't configure repeat)
if CONF_REPEAT not in config:
template_ = await cg.templatable(3, args, cg.uint32)
cg.add(var.set_send_times(template_))
template_ = await cg.templatable(36000, args, cg.uint32)
cg.add(var.set_send_wait(template_))
@@ -35,8 +35,6 @@ template<typename... Ts> class TotoAction : public RemoteTransmitterActionBase<T
data.rc_code_1 = this->rc_code_1_.value(x...);
data.rc_code_2 = this->rc_code_2_.value(x...);
data.command = this->command_.value(x...);
this->set_send_times(this->send_times_.value_or(x..., 3));
this->set_send_wait(this->send_wait_.value_or(x..., 36000));
TotoProtocol().encode(dst, data);
}
};
@@ -312,7 +312,8 @@ async def set_playlist_delay_action_to_code(
parent = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, parent)
cg.add(var.set_pipeline(config[CONF_PIPELINE]))
template_ = await cg.templatable(config[CONF_PIPELINE], args, cg.uint8)
cg.add(var.set_pipeline(template_))
template_ = await cg.templatable(config[CONF_DELAY], args, cg.uint32)
cg.add(var.set_delay(template_))
+3 -3
View File
@@ -455,7 +455,7 @@ async def sprinkler_set_multiplier_to_code(config, action_id, template_arg, args
async def sprinkler_set_queued_valve_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.uint8)
template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t)
cg.add(var.set_valve_number(template_))
template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32)
cg.add(var.set_valve_run_duration(template_))
@@ -487,7 +487,7 @@ async def sprinkler_set_valve_run_duration_to_code(
):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.uint8)
template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t)
cg.add(var.set_valve_number(template_))
template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32)
cg.add(var.set_valve_run_duration(template_))
@@ -525,7 +525,7 @@ async def sprinkler_start_full_cycle_to_code(config, action_id, template_arg, ar
async def sprinkler_start_single_valve_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.uint8)
template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t)
cg.add(var.set_valve_to_start(template_))
if CONF_RUN_DURATION in config:
template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32)
+3 -1
View File
@@ -108,7 +108,8 @@ template<typename... Ts> class StartSingleValveAction : public Action<Ts...> {
public:
explicit StartSingleValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {}
TEMPLATABLE_VALUE(size_t, valve_to_start)
// TemplatableValue (not TemplatableFn) — also set from C++ with raw values in sprinkler.cpp
template<typename V> void set_valve_to_start(V valve_to_start) { this->valve_to_start_ = valve_to_start; }
TEMPLATABLE_VALUE(uint32_t, valve_run_duration)
void play(const Ts &...x) override {
@@ -118,6 +119,7 @@ template<typename... Ts> class StartSingleValveAction : public Action<Ts...> {
protected:
Sprinkler *sprinkler_;
TemplatableValue<size_t, Ts...> valve_to_start_{};
};
template<typename... Ts> class ShutdownAction : public Action<Ts...> {
@@ -2,6 +2,7 @@ from collections import ChainMap
import logging
from typing import Any
import esphome
from esphome import core
from esphome.config_helpers import Extend, Remove, merge_config, merge_dicts_ordered
import esphome.config_validation as cv
@@ -12,6 +13,7 @@ from esphome.yaml_util import (
ConfigContext,
ESPHomeDataBase,
ESPLiteralValue,
IncludeFile,
make_data_base,
)
@@ -291,6 +293,59 @@ def push_context(
return parent_context
def resolve_include(
include: IncludeFile,
path: list[int | str],
context_vars: ContextVars,
strict_undefined: bool = True,
errors: ErrList | None = None,
) -> tuple[Any, str]:
"""Resolve an include, substituting the filename if needed.
Returns the loaded content and the resolved filename.
Note: no path-traversal validation is performed on the resolved filename.
A substitution that resolves to an absolute path will bypass the parent
directory (Path.__truediv__ ignores the left operand for absolute paths).
ESPHome's trust model assumes the config author controls all substitution
values (including command-line substitutions), so path restrictions are
an explicit non-goal here.
"""
original = str(include.file)
filename = str(
_expand_substitutions(
original, path + ["file"], context_vars, strict_undefined, errors
)
)
if filename != original:
include = IncludeFile(
include.parent_file, filename, include.vars, include.yaml_loader
)
try:
return include.load(), filename
except esphome.core.EsphomeError as err:
raise cv.Invalid(
f"Error including file '{filename}': {err}",
path + [f"<{filename}>"],
) from err
def _substitute_include(
include: IncludeFile,
path: list[int | str],
context_vars: ContextVars,
strict_undefined: bool,
errors: ErrList | None,
) -> Any:
"""Resolve an include and substitute its content."""
content, filename = resolve_include(
include, path, context_vars, strict_undefined, errors
)
return substitute(
content, path + [f"<{filename}>"], context_vars, strict_undefined, errors
)
def substitute(
item: Any,
path: SubstitutionPath,
@@ -333,6 +388,9 @@ def substitute(
if item.value != value:
result = type(item)(value)
elif isinstance(item, IncludeFile):
result = _substitute_include(item, path, context_vars, strict_undefined, errors)
if isinstance(item, ESPHomeDataBase):
result = make_data_base(result, item)
return result
+3 -13
View File
@@ -2,7 +2,6 @@ from ast import literal_eval
from collections.abc import Iterator, Mapping
from itertools import chain, islice
import math
import re
from types import GeneratorType
from typing import Any
@@ -10,6 +9,9 @@ import jinja2 as jinja
from jinja2.nativetypes import NativeCodeGenerator, NativeTemplate
from jinja2.runtime import missing as Missing
# Re-exported for backward compatibility — consumers import has_jinja from here
from esphome.expression import has_jinja # noqa: F401 # pylint: disable=unused-import
TemplateError = jinja.TemplateError
TemplateSyntaxError = jinja.TemplateSyntaxError
TemplateRuntimeError = jinja.TemplateRuntimeError
@@ -20,18 +22,6 @@ Undefined = jinja.Undefined
Resolver = ".resolver"
DETECT_JINJA = r"(\$\{)"
detect_jinja_re = re.compile(
r"<%.+?%>" # Block form expression: <% ... %>
r"|\$\{[^}]+\}", # Braced form expression: ${ ... }
flags=re.MULTILINE,
)
def has_jinja(st: str) -> bool:
return detect_jinja_re.search(st) is not None
# SAFE_GLOBALS defines a allowlist of built-in functions or modules that are considered safe to expose
# in Jinja templates or other sandboxed evaluation contexts. Only functions that do not allow
# arbitrary code execution, file access, or other security risks are included.
+1 -6
View File
@@ -75,7 +75,6 @@ from esphome.const import (
SCHEDULER_DONT_RUN,
TYPE_GIT,
TYPE_LOCAL,
VALID_SUBSTITUTIONS_CHARACTERS,
Framework,
__version__ as ESPHOME_VERSION,
)
@@ -90,6 +89,7 @@ from esphome.core import (
TimePeriodNanoseconds,
TimePeriodSeconds,
)
from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG
from esphome.helpers import add_class_to_obj, docs_url, list_starts_with
from esphome.schema_extractors import (
SCHEMA_EXTRACT,
@@ -104,11 +104,6 @@ from esphome.yaml_util import make_data_base
_LOGGER = logging.getLogger(__name__)
# pylint: disable=consider-using-f-string
VARIABLE_PROG = re.compile(
f"\\$([{VALID_SUBSTITUTIONS_CHARACTERS}]+|\\{{[{VALID_SUBSTITUTIONS_CHARACTERS}]*\\}})"
)
# pylint: disable=invalid-name
Schema = _Schema
+52 -18
View File
@@ -54,8 +54,12 @@ template<typename T, typename... X> class TemplatableFn {
std::invocable<F, X...> &&std::convertible_to<std::invoke_result_t<F, X...>, T> &&std::is_empty_v<F>
&&std::default_initializable<F> : f_([](X... x) -> T { return static_cast<T>(F{}(x...)); }) {}
// Reject stateful lambdas (non-empty, i.e. capturing) with a clear error
template<typename F> TemplatableFn(F) requires std::invocable<F, X...> &&(!std::is_empty_v<F>) = delete;
// Reject any callable that didn't match the above (stateful lambdas or inconvertible return types)
template<typename F>
TemplatableFn(F) requires std::invocable<F, X...> &&
(!std::convertible_to<F, T (*)(X...)>) &&(!std::is_empty_v<F> ||
!std::convertible_to<std::invoke_result_t<F, X...>, T> ||
!std::default_initializable<F>) = delete;
bool has_value() const { return this->f_ != nullptr; }
@@ -106,9 +110,21 @@ template<typename T, typename... X> class TemplatableValue {
// Accept stateless lambdas (convertible to function pointer)
template<typename F> TemplatableValue(F f) requires std::convertible_to<F, T (*)(X...)> : tag_(FN) { this->f_ = f; }
// Reject stateful lambdas at compile time
// Convertible return type (e.g., int -> uint8_t) — casting trampoline
template<typename F>
TemplatableValue(F) requires std::invocable<F, X...> &&(!std::convertible_to<F, T (*)(X...)>) = delete;
[[deprecated("Lambda return type does not match TemplatableValue<T> — use the correct type in "
"codegen")]] TemplatableValue(F) requires(!std::convertible_to<F, T (*)(X...)>) &&
std::invocable<F, X...> &&std::convertible_to<std::invoke_result_t<F, X...>, T> &&std::is_empty_v<F>
&&std::default_initializable<F> : tag_(FN) {
this->f_ = [](X... x) -> T { return static_cast<T>(F{}(x...)); };
}
// Reject any callable that didn't match the above
template<typename F>
TemplatableValue(F) requires std::invocable<F, X...> &&
(!std::convertible_to<F, T (*)(X...)>) &&(!std::is_empty_v<F> ||
!std::convertible_to<std::invoke_result_t<F, X...>, T> ||
!std::default_initializable<F>) = delete;
TemplatableValue(const TemplatableValue &other) : tag_(other.tag_) {
if (this->tag_ == VALUE) {
@@ -222,7 +238,7 @@ template<typename... X> class TemplatableValue<std::string, X...> {
template<typename F>
TemplatableValue(F f) requires std::invocable<F, X...> && std::convertible_to<F, std::string (*)(X...)>
: type_(STATELESS_LAMBDA) {
this->stateless_f_ = f;
this->stateless_f_ = f; // Implicit conversion to function pointer
}
// For stateful lambdas (not convertible to function pointer): use std::function
@@ -232,6 +248,7 @@ template<typename... X> class TemplatableValue<std::string, X...> {
this->f_ = new std::function<std::string(X...)>(std::move(f));
}
// Copy constructor
TemplatableValue(const TemplatableValue &other) : type_(other.type_) {
if (this->type_ == VALUE) {
this->value_ = new std::string(*other.value_);
@@ -244,6 +261,7 @@ template<typename... X> class TemplatableValue<std::string, X...> {
}
}
// Move constructor
TemplatableValue(TemplatableValue &&other) noexcept : type_(other.type_) {
if (this->type_ == VALUE) {
this->value_ = other.value_;
@@ -259,6 +277,7 @@ template<typename... X> class TemplatableValue<std::string, X...> {
other.type_ = NONE;
}
// Assignment operators
TemplatableValue &operator=(const TemplatableValue &other) {
if (this != &other) {
this->~TemplatableValue();
@@ -281,6 +300,7 @@ template<typename... X> class TemplatableValue<std::string, X...> {
} else if (this->type_ == LAMBDA) {
delete this->f_;
}
// STATELESS_LAMBDA/STATIC_STRING/FLASH_STRING/NONE: no cleanup needed (pointers, not heap-allocated)
}
bool has_value() const { return this->type_ != NONE; }
@@ -288,15 +308,16 @@ template<typename... X> class TemplatableValue<std::string, X...> {
std::string value(X... x) const {
switch (this->type_) {
case STATELESS_LAMBDA:
return this->stateless_f_(x...);
return this->stateless_f_(x...); // Direct function pointer call
case LAMBDA:
return (*this->f_)(x...);
return (*this->f_)(x...); // std::function call
case VALUE:
return *this->value_;
case STATIC_STRING:
return std::string(this->static_str_);
#ifdef USE_ESP8266
case FLASH_STRING: {
// PROGMEM pointer — must use _P functions to access on ESP8266
size_t len = strlen_P(this->static_str_);
std::string result(len, '\0');
memcpy_P(result.data(), this->static_str_, len);
@@ -321,13 +342,18 @@ template<typename... X> class TemplatableValue<std::string, X...> {
return this->value(x...);
}
/// Check if this holds a static string (const char* stored without allocation).
/// Check if this holds a static string (const char* stored without allocation)
/// The pointer is always directly readable (RAM or flash-mapped).
/// Returns false for FLASH_STRING (PROGMEM on ESP8266, requires _P functions).
bool is_static_string() const { return this->type_ == STATIC_STRING; }
/// Get the static string pointer (only valid if is_static_string() returns true).
/// Get the static string pointer (only valid if is_static_string() returns true)
/// The pointer is always directly readable — FLASH_STRING uses a separate type.
const char *get_static_string() const { return this->static_str_; }
/// Check if the string value is empty without allocating.
/// For NONE, returns true. For STATIC_STRING/VALUE, checks without allocation.
/// For LAMBDA/STATELESS_LAMBDA, must call value() which may allocate.
bool is_empty() const {
switch (this->type_) {
case NONE:
@@ -336,17 +362,24 @@ template<typename... X> class TemplatableValue<std::string, X...> {
return this->static_str_ == nullptr || this->static_str_[0] == '\0';
#ifdef USE_ESP8266
case FLASH_STRING:
// PROGMEM pointer — must use progmem_read_byte on ESP8266
return this->static_str_ == nullptr ||
progmem_read_byte(reinterpret_cast<const uint8_t *>(this->static_str_)) == '\0';
#endif
case VALUE:
return this->value_->empty();
default:
default: // LAMBDA/STATELESS_LAMBDA - must call value()
return this->value().empty();
}
}
/// Get a StringRef without heap allocation when possible.
/// Get a StringRef to the string value without heap allocation when possible.
/// For STATIC_STRING/VALUE, returns reference to existing data (no allocation).
/// For FLASH_STRING (ESP8266 PROGMEM), copies to provided buffer via _P functions.
/// For LAMBDA/STATELESS_LAMBDA, calls value(), copies to provided buffer, returns ref to buffer.
/// @param lambda_buf Buffer used only for copy cases (must remain valid while StringRef is used).
/// @param lambda_buf_size Size of the buffer.
/// @return StringRef pointing to the string data.
StringRef ref_or_copy_to(char *lambda_buf, size_t lambda_buf_size) const {
switch (this->type_) {
case NONE:
@@ -360,6 +393,7 @@ template<typename... X> class TemplatableValue<std::string, X...> {
if (this->static_str_ == nullptr)
return StringRef();
{
// PROGMEM pointer — copy to buffer via _P functions
size_t len = strlen_P(this->static_str_);
size_t copy_len = std::min(len, lambda_buf_size - 1);
memcpy_P(lambda_buf, this->static_str_, copy_len);
@@ -369,7 +403,7 @@ template<typename... X> class TemplatableValue<std::string, X...> {
#endif
case VALUE:
return StringRef(this->value_->data(), this->value_->size());
default: {
default: { // LAMBDA/STATELESS_LAMBDA - must call value() and copy
std::string result = this->value();
size_t copy_len = std::min(result.size(), lambda_buf_size - 1);
memcpy(lambda_buf, result.data(), copy_len);
@@ -385,14 +419,14 @@ template<typename... X> class TemplatableValue<std::string, X...> {
VALUE,
LAMBDA,
STATELESS_LAMBDA,
STATIC_STRING,
FLASH_STRING,
STATIC_STRING, // For const char* — avoids heap allocation
FLASH_STRING, // PROGMEM pointer on ESP8266; never set on other platforms
} type_;
union {
std::string *value_;
std::function<std::string(X...)> *f_;
std::string (*stateless_f_)(X...);
const char *static_str_;
std::string *value_; // Heap-allocated string (VALUE)
std::function<std::string(X...)> *f_; // Heap-allocated std::function (LAMBDA)
std::string (*stateless_f_)(X...); // Function pointer (STATELESS_LAMBDA)
const char *static_str_; // For STATIC_STRING and FLASH_STRING types
};
};
+2
View File
@@ -300,6 +300,8 @@
#define USE_ETHERNET_OPENETH
#define USE_ETHERNET_W5100
#define USE_ETHERNET_W5500
#define USE_ETHERNET_W6100
#define USE_ETHERNET_W6300
#define USE_ETHERNET_DM9051
#define CONFIG_ETH_SPI_ETHERNET_W5500 1
#define CONFIG_ETH_SPI_ETHERNET_DM9051 1
+2 -2
View File
@@ -54,8 +54,8 @@ namespace esphome {
#define ESPHOME_LOG_COLOR_CYAN "36" // DEBUG
#define ESPHOME_LOG_COLOR_GRAY "37" // VERBOSE
#define ESPHOME_LOG_COLOR_WHITE "38"
#define ESPHOME_LOG_SECRET_BEGIN "\033[5m"
#define ESPHOME_LOG_SECRET_END "\033[6m"
#define ESPHOME_LOG_SECRET_BEGIN "\033[8m"
#define ESPHOME_LOG_SECRET_END "\033[28m"
#define LOG_SECRET(x) ESPHOME_LOG_SECRET_BEGIN x ESPHOME_LOG_SECRET_END
#define ESPHOME_LOG_COLOR(COLOR) "\033[0;" COLOR "m"
+14 -15
View File
@@ -835,27 +835,26 @@ async def templatable(
"""
if is_template(value):
return await process_lambda(value, args, return_type=output_type)
# Late import to avoid circular dependency (cpp_generator <-> cpp_types).
from esphome.cpp_types import std_string
if to_exp is not None:
value = to_exp[value] if isinstance(to_exp, dict) else to_exp(value)
elif isinstance(value, str) and output_type is not None:
elif (
isinstance(value, str) and output_type is not None and output_type is std_string
):
# Automatically wrap static strings in ESPHOME_F() for PROGMEM storage on ESP8266.
# On other platforms ESPHOME_F() is a no-op returning const char*.
from esphome.cpp_types import std_string
if output_type is std_string:
return FlashStringLiteral(value)
return FlashStringLiteral(value)
# For non-string types, wrap constants in stateless lambdas so that
# TemplatableFn (used by TEMPLATABLE_VALUE macro) stores them as function pointers.
if output_type is not None:
from esphome.cpp_types import std_string
if output_type is not std_string:
return LambdaExpression(
f"return {safe_exp(value)};",
args,
capture="",
return_type=output_type,
)
if output_type is not None and output_type is not std_string:
return LambdaExpression(
f"return {safe_exp(value)};",
args,
capture="",
return_type=output_type,
)
return value
+25
View File
@@ -0,0 +1,25 @@
"""Helpers for detecting substitution variables and Jinja expressions."""
import re
from esphome.const import VALID_SUBSTITUTIONS_CHARACTERS
SUBSTITUTION_VARIABLE_PROG = re.compile(
rf"\$([{VALID_SUBSTITUTIONS_CHARACTERS}]+|\{{[{VALID_SUBSTITUTIONS_CHARACTERS}]*\}})"
)
_JINJA_RE = re.compile(
r"<%.+?%>" # Block: <% ... %>
r"|\$\{[^}]+\}", # Braced: ${ ... }
flags=re.MULTILINE,
)
def has_jinja(value: str) -> bool:
"""Check if a string contains Jinja expressions."""
return _JINJA_RE.search(value) is not None
def has_substitution_or_expression(value: str) -> bool:
"""Check if a string contains substitution variables ($name, ${name}) or Jinja expressions."""
return SUBSTITUTION_VARIABLE_PROG.search(value) is not None or has_jinja(value)
+137 -20
View File
@@ -33,6 +33,7 @@ from esphome.core import (
MACAddress,
TimePeriod,
)
from esphome.expression import has_substitution_or_expression
from esphome.helpers import add_class_to_obj
from esphome.util import OrderedDict, filter_yaml_files
@@ -110,24 +111,6 @@ def make_data_base(
return value
class ConfigContext:
"""This is a mixin class that holds substitution vars that should be applied
to the tagged node and its children. During configuration loading, context vars can
be added to nodes using `add_context` function, which applies the mixin storing
the captured values and unevaluated expressions.
The substitution pass then recreates the effective context by merging the context vars
from this node and parent nodes.
"""
@property
def vars(self) -> dict[str, Any]:
return self._context_vars
def set_context(self, vars: dict[str, Any]) -> None:
# pylint: disable=attribute-defined-outside-init
self._context_vars = vars
def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any:
"""Tags a list/string/dict value with context vars that must be applied to it and its children
during the substitution pass. If no vars are given, no tagging is done.
@@ -151,6 +134,94 @@ def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any:
return value
class ConfigContext:
"""This is a mixin class that holds substitution vars that should be applied
to the tagged node and its children. During configuration loading, context vars can
be added to nodes using `add_context` function, which applies the mixin storing
the captured values and unevaluated expressions.
The substitution pass then recreates the effective context by merging the context vars
from this node and parent nodes.
"""
@property
def vars(self) -> dict[str, Any]:
return self._context_vars
def set_context(self, vars: dict[str, Any]) -> None:
# pylint: disable=attribute-defined-outside-init
self._context_vars = vars
def copy_context_to_children(self) -> None:
"""Propagate context to children.
isinstance(self, dict/list) works because ConfigContext is dynamically
mixed into dict/list subclasses via add_class_to_obj in add_context().
"""
if isinstance(self, dict):
# pylint: disable=no-member
tagged = {
add_context(k, self.vars): add_context(v, self.vars)
for k, v in self.items()
}
self.clear()
self.update(tagged)
elif isinstance(self, list):
for i, item in enumerate(self):
# pylint: disable=unsupported-assignment-operation
self[i] = add_context(item, self.vars)
_UNSET = object()
class IncludeFile:
"""Deferred !include that is resolved during the substitution pass.
Created during YAML parsing instead of loading the file immediately,
allowing substitution variables to appear in the filename path
(e.g. ``!include device-${platform}.yaml``). The actual file is
loaded on the first call to ``load()``, and the result is cached.
"""
def __init__(
self,
parent_file: Path,
file: Path | str,
vars: dict[str, Any] | None,
yaml_loader: Callable[[Path], Any],
) -> None:
self.parent_file = parent_file
self.file = Path(file)
self.vars = vars
self.yaml_loader = yaml_loader
self._content: Any = _UNSET
def __repr__(self) -> str:
return f"IncludeFile({self.file.as_posix()})"
def load(self) -> Any:
"""Load and cache the included file content.
Note: returns the cached mutable object on subsequent calls.
Callers that need to modify the result should copy it first.
"""
if self._content is not _UNSET:
return self._content
if self.has_unresolved_expressions():
from esphome.config_validation import Invalid
raise Invalid(
f"Cannot load include with unresolved substitutions: {self.file}"
)
self._content = self.yaml_loader(Path(self.parent_file.parent / self.file))
self._content = add_context(self._content, self.vars)
return self._content
def has_unresolved_expressions(self) -> bool:
"""Check if the filename contains substitution variables or Jinja expressions."""
return has_substitution_or_expression(str(self.file))
def _add_data_ref(fn):
@functools.wraps(fn)
def wrapped(loader, node):
@@ -170,6 +241,36 @@ def _add_data_ref(fn):
return wrapped
_MAX_MERGE_INCLUDE_DEPTH = 10
def _resolve_merge_include(value: Any, node: yaml.Node, value_node: yaml.Node) -> Any:
"""Resolve an IncludeFile (and chains) and propagate context for merge key handling."""
for _ in range(_MAX_MERGE_INCLUDE_DEPTH):
if not isinstance(value, IncludeFile):
break
if value.has_unresolved_expressions():
raise yaml.constructor.ConstructorError(
"While constructing a mapping",
node.start_mark,
"Substitution in include filename with merge keys is not supported yet.",
value_node.start_mark,
)
value = value.load()
else:
raise yaml.constructor.ConstructorError(
"While constructing a mapping",
node.start_mark,
f"Maximum include chain depth ({_MAX_MERGE_INCLUDE_DEPTH}) exceeded in merge key",
value_node.start_mark,
)
if isinstance(value, ConfigContext):
# Since the parent dict/list will disappear, propagate
# context to children now to retain context vars
value.copy_context_to_children()
return value
class ESPHomeLoaderMixin:
"""Loader class that keeps track of line numbers."""
@@ -261,6 +362,9 @@ class ESPHomeLoaderMixin:
# This is a merge key, resolve value and add to merge_pairs
value = self.construct_object(value_node)
value = _resolve_merge_include(value, node, value_node)
if isinstance(value, dict):
# base case, copy directly to merge_pairs
# direct merge, like "<<: {some_key: some_value}"
@@ -268,6 +372,7 @@ class ESPHomeLoaderMixin:
elif isinstance(value, list):
# sequence merge, like "<<: [{some_key: some_value}, {other_key: some_value}]"
for item in value:
item = _resolve_merge_include(item, node, value_node)
if not isinstance(item, dict):
raise yaml.constructor.ConstructorError(
"While constructing a mapping",
@@ -362,8 +467,11 @@ class ESPHomeLoaderMixin:
else:
file, vars = node.value, None
result = self.yaml_loader(self._rel_path(file))
return add_context(result, vars)
return IncludeFile(self.name, file, vars, self.yaml_loader)
# Directory includes (!include_dir_*) load eagerly during YAML parsing
# because their paths are directory names, not individual files, and
# substitutions in directory paths are not supported.
@_add_data_ref
def construct_include_dir_list(self, node: yaml.Node) -> list[dict[str, Any]]:
@@ -646,6 +754,14 @@ class ESPHomeDumper(yaml.SafeDumper):
def represent_remove(self, value):
return self.represent_scalar(tag="!remove", value=value.value)
def represent_include_file(self, value):
if value.vars:
mapping = {"file": value.file.as_posix(), "vars": value.vars}
return self.represent_mapping(
tag="!include", mapping=mapping, flow_style=False
)
return self.represent_scalar(tag="!include", value=value.file.as_posix())
def represent_id(self, value):
if is_secret(value.id):
return self.represent_secret(value.id)
@@ -677,3 +793,4 @@ ESPHomeDumper.add_multi_representer(Remove, ESPHomeDumper.represent_remove)
ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id)
ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify)
ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_stringify)
ESPHomeDumper.add_multi_representer(IncludeFile, ESPHomeDumper.represent_include_file)
+2 -2
View File
@@ -1,4 +1,4 @@
cryptography==46.0.6
cryptography==46.0.7
voluptuous==0.16.0
PyYAML==6.0.3
paho-mqtt==1.6.1
@@ -11,7 +11,7 @@ pyserial==3.5
platformio==6.1.19
esptool==5.2.0
click==8.3.2
esphome-dashboard==20260210.0
esphome-dashboard==20260408.1
aioesphomeapi==44.12.0
zeroconf==0.148.0
puremagic==1.30
+32
View File
@@ -1006,6 +1006,38 @@ def lint_log_in_header(fname, line, col, content):
)
PACKAGE_BUS_RE = re.compile(
r"^\s+(\w+):\s*!include\s+\S*test_build_components/common/(\w+)/",
re.MULTILINE,
)
@lint_content_check(include=["tests/components/*/test.*.yaml"])
def lint_test_package_key_matches_bus(fname, content):
"""Ensure package keys match the common bus directory name.
For example, a package using uart_115200 includes must use
'uart_115200' as the key, not 'uart'.
"""
errs: list[tuple[int, int, str]] = []
for match in PACKAGE_BUS_RE.finditer(content):
pkg_key = match.group(1)
bus_dir = match.group(2)
if pkg_key != bus_dir:
lineno = content.count("\n", 0, match.start()) + 1
errs.append(
(
lineno,
1,
f"Package key {highlight(pkg_key)} does not match bus directory "
f"{highlight(bus_dir)}. The package key must match the directory "
f"name under tests/test_build_components/common/. "
f"Change {highlight(pkg_key)} to {highlight(bus_dir)}.",
)
)
return errs
@lint_content_find_check(
"FINAL_VALIDATE_SCHEMA",
include=["esphome/core/*.py"],
+1 -1
View File
@@ -1,4 +1,4 @@
packages:
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
<<: !include common.yaml
@@ -1,4 +1,4 @@
packages:
uart: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
<<: !include common.yaml
+1 -1
View File
@@ -1,4 +1,4 @@
packages:
uart: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml
uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml
<<: !include common.yaml
@@ -78,6 +78,7 @@ display:
model: seeed-reterminal-e1002
- platform: epaper_spi
model: seeed-ee04-mono-4.26
full_update_every: 10
# Override pins to avoid conflict with other display configs
busy_pin: 43
dc_pin: 42
@@ -0,0 +1,18 @@
ethernet:
type: W6100
clk_pin: 18
mosi_pin: 19
miso_pin: 16
cs_pin: 17
interrupt_pin: 21
reset_pin: 20
manual_ip:
static_ip: 192.168.178.56
gateway: 192.168.178.1
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -0,0 +1,18 @@
ethernet:
type: W6300
clk_pin: 18
mosi_pin: 19
miso_pin: 16
cs_pin: 17
interrupt_pin: 21
reset_pin: 20
manual_ip:
static_ip: 192.168.178.56
gateway: 192.168.178.1
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -0,0 +1 @@
<<: !include common-w6100-rp2040.yaml
@@ -0,0 +1 @@
<<: !include common-w6300-rp2040.yaml
+2 -2
View File
@@ -21,7 +21,7 @@ binary_sensor:
ignore_strapping_warning: true
display:
- platform: ili9xxx
- platform: mipi_spi
spi_id: spi_bus
model: st7789v
id: second_display
@@ -41,7 +41,7 @@ display:
invert_colors: false
update_interval: never
- platform: ili9xxx
- platform: mipi_spi
spi_id: spi_bus
model: st7789v
id: tft_display
@@ -0,0 +1,12 @@
display:
- platform: mipi_dsi
model: WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C
lvgl:
byte_order: little_endian
rotation: 90
psram:
esp_ldo:
- channel: 3
voltage: 2.5V
@@ -0,0 +1,15 @@
values:
- var1: 4
- a: 5
- b: 6
- c: The value of C is 7
- This value comes from inc2.yaml. x is 3, y is 4
- From main config, x is 3, y is 2
- $a $b $c are out of scope here
- keys_in_inc3:
x: 3
y: 2
substitutions:
x: 3
y: 2
include_file: inc1
@@ -0,0 +1,21 @@
substitutions:
include_file: inc1
x: 3 # override x from inc2.yaml
packages:
my_package: !include
file: ${include_file + ".yaml"} # includes inc1.yaml
vars:
var1: 4
a: ${x+2}
b: ${a+1}
c: 7
other_package: !include
file: inc${1+1}.yaml # includes inc2.yaml
vars:
y: 4
values:
- From main config, x is $x, y is $y
- $a $b $c are out of scope here
- !include ${"inc" + "3.yaml"} # includes inc3.yaml here (not a package)
@@ -0,0 +1,9 @@
substitutions:
x: 7
test_list:
- content:
before: Content before
after: Content after
keys_in_inc3:
x: 7
y: 8
@@ -0,0 +1,10 @@
substitutions:
x: 7
test_list:
- content:
before: Content before
<<: !include
file: inc3.yaml
vars:
y: 8
after: Content after
@@ -0,0 +1,6 @@
substitutions:
x: 1
y: 2
values:
- This value comes from inc2.yaml. x is $x, y is $y
@@ -0,0 +1,3 @@
keys_in_inc3:
x: ${x}
y: ${y}
+9
View File
@@ -684,6 +684,15 @@ async def test_templatable__with_to_exp_callable() -> None:
assert result == 84
@pytest.mark.asyncio
async def test_templatable__with_to_exp_callable_and_output_type() -> None:
"""When to_exp is provided with non-string output_type, result is lambda-wrapped."""
result = await cg.templatable(42, [], ct.int_, to_exp=lambda x: x * 2)
assert isinstance(result, cg.LambdaExpression)
assert result.capture == ""
@pytest.mark.asyncio
async def test_templatable__with_to_exp_dict() -> None:
"""When to_exp is a dict, value is looked up."""
+66 -2
View File
@@ -8,12 +8,17 @@ import pytest
from esphome import config as config_module, yaml_util
from esphome.components import substitutions
from esphome.components.packages import do_packages_pass, merge_packages
from esphome.components.packages import (
MAX_INCLUDE_DEPTH,
_PackageProcessor,
do_packages_pass,
merge_packages,
)
from esphome.config import resolve_extend_remove
from esphome.config_helpers import Extend, merge_config
import esphome.config_validation as cv
from esphome.const import CONF_SUBSTITUTIONS
from esphome.core import CORE, Lambda
from esphome.core import CORE, EsphomeError, Lambda
from esphome.util import OrderedDict
_LOGGER = logging.getLogger(__name__)
@@ -630,3 +635,62 @@ def test_do_substitution_pass_substitutions_must_be_mapping_from_config() -> Non
cv.Invalid, match="Substitutions must be a key to value mapping"
):
substitutions.do_substitution_pass(config)
# ── IncludeFile / package loading tests ────────────────────────────────────
def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None:
"""A yaml_loader that always returns another IncludeFile triggers the depth guard."""
parent = tmp_path / "main.yaml"
parent.write_text("")
# Each call to the loader returns a fresh IncludeFile pointing at itself,
# so PACKAGE_SCHEMA always sees an IncludeFile and never a dict.
def always_returns_include(path: Path) -> yaml_util.IncludeFile:
return yaml_util.IncludeFile(parent, path.name, None, always_returns_include)
package_config = yaml_util.IncludeFile(
parent, "test.yaml", None, always_returns_include
)
processor = _PackageProcessor({}, None, False)
with pytest.raises(
cv.Invalid,
match=f"Maximum include nesting depth \\({MAX_INCLUDE_DEPTH}\\) exceeded",
):
processor.resolve_package(package_config, substitutions.ContextVars())
def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None:
"""!include with an undefined substitution variable raises cv.Invalid.
The error message must reference the unresolved filename template so the
user knows which include failed, rather than seeing a bare file-not-found.
"""
main_file = tmp_path / "main.yaml"
main_file.write_text("result: !include ${undefined_var}.yaml\n")
config = yaml_util.load_yaml(main_file)
with pytest.raises(cv.Invalid, match=r"\$\{undefined_var\}"):
substitutions.do_substitution_pass(config)
def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> None:
"""An undefined substitution in a package include filename raises cv.Invalid.
Previously this would raise an unhandled UndefinedError. With
strict_undefined=False, the unresolved filename passes through to
file loading which produces a clean cv.Invalid error.
"""
parent = tmp_path / "main.yaml"
parent.write_text("")
def loader(path: Path):
raise EsphomeError(f"Error reading file {path}: No such file")
package_config = yaml_util.IncludeFile(
parent, "${undefined_var}.yaml", None, loader
)
processor = _PackageProcessor({}, None, False)
with pytest.raises(cv.Invalid, match="unresolved substitutions"):
processor.resolve_package(package_config, substitutions.ContextVars())
+202 -2
View File
@@ -1,3 +1,4 @@
import io
from pathlib import Path
import shutil
from unittest.mock import patch
@@ -7,6 +8,7 @@ import pytest
from esphome import core, yaml_util
from esphome.components import substitutions
from esphome.config_helpers import Extend, Remove
import esphome.config_validation as cv
from esphome.core import EsphomeError
from esphome.util import OrderedDict
@@ -74,7 +76,9 @@ def test_parsing_with_custom_loader(fixture_path):
loader_calls.append(fname)
with yaml_file.open(encoding="utf-8") as f_handle:
yaml_util.parse_yaml(yaml_file, f_handle, custom_loader)
config = yaml_util.parse_yaml(yaml_file, f_handle, custom_loader)
# substitute config to expand includes:
substitutions.substitute(config, [], substitutions.ContextVars(), False)
assert len(loader_calls) == 3
assert loader_calls[0].parts[-2:] == ("includes", "included.yaml")
@@ -348,7 +352,9 @@ def test_track_yaml_loads_records_includes(tmp_path: Path) -> None:
main.write_text("child: !include included.yaml\n")
with yaml_util.track_yaml_loads() as loaded:
yaml_util.load_yaml(main)
result = yaml_util.load_yaml(main)
# !include is deferred; resolve it to trigger the nested load
result["child"].load()
resolved = [p.name for p in loaded]
assert "main.yaml" in resolved
@@ -500,3 +506,197 @@ def test_represent_extend() -> None:
def test_represent_remove() -> None:
"""Test that Remove objects are dumped as plain !remove scalars."""
assert yaml_util.dump({"key": Remove("my_id")}) == "key: !remove 'my_id'\n"
def test_represent_include_file() -> None:
"""Test that IncludeFile objects are dumped as !include scalars."""
include = yaml_util.IncludeFile(
Path("/fake/main.yaml"), "path/to/file.yaml", None, lambda _: {}
)
assert yaml_util.dump({"key": include}) == "key: !include 'path/to/file.yaml'\n"
def test_represent_include_file_with_vars() -> None:
"""Test that IncludeFile with vars is dumped as !include mapping form."""
include = yaml_util.IncludeFile(
Path("/fake/main.yaml"),
"path/to/file.yaml",
{"key": "value"},
lambda _: {},
)
result = yaml_util.dump({"key": include})
assert "!include" in result
assert "file: path/to/file.yaml" in result
assert "key: value" in result
def test_represent_include_file_with_data_base_mixin() -> None:
"""Test that IncludeFile wrapped with ESPHomeDataBase mixin is also dumped correctly.
The YAML loader wraps IncludeFile via add_class_to_obj, creating a dynamic
subclass. add_multi_representer must match this subclass through the MRO.
"""
include = yaml_util.IncludeFile(
Path("/fake/main.yaml"), "common/spi.yaml", None, lambda _: {}
)
wrapped = yaml_util.make_data_base(include)
assert isinstance(wrapped, yaml_util.ESPHomeDataBase)
assert yaml_util.dump({"pkg": wrapped}) == "pkg: !include 'common/spi.yaml'\n"
# ── IncludeFile unit tests ──────────────────────────────────────────────────
def test_include_file_repr(tmp_path: Path) -> None:
"""repr() includes the filename so it appears usefully in error messages."""
parent = tmp_path / "main.yaml"
include = yaml_util.IncludeFile(parent, "some/nested.yaml", None, lambda _: {})
assert repr(include) == "IncludeFile(some/nested.yaml)"
def test_include_file_load_caches_result(tmp_path: Path) -> None:
"""load() invokes the yaml_loader only once; subsequent calls return the cached object."""
parent = tmp_path / "main.yaml"
content = {"key": "value"}
call_count = 0
def counting_loader(_):
nonlocal call_count
call_count += 1
return content
include = yaml_util.IncludeFile(parent, "child.yaml", None, counting_loader)
first = include.load()
second = include.load()
assert call_count == 1
assert first is second
def test_include_file_load_caches_none_result(tmp_path: Path) -> None:
"""load() caches None content (empty YAML files) and does not re-invoke the loader."""
parent = tmp_path / "main.yaml"
call_count = 0
def counting_loader(_):
nonlocal call_count
call_count += 1
include = yaml_util.IncludeFile(parent, "empty.yaml", None, counting_loader)
first = include.load()
second = include.load()
assert call_count == 1
assert first is None
assert second is None
def test_include_file_load_raises_on_unresolved_expressions(tmp_path: Path) -> None:
"""load() raises if the filename contains unresolved substitutions or expressions."""
parent = tmp_path / "main.yaml"
include = yaml_util.IncludeFile(parent, "${undefined_var}.yaml", None, lambda _: {})
with pytest.raises(cv.Invalid, match="unresolved"):
include.load()
@pytest.mark.parametrize(
("filename", "expected"),
[
("device-${platform}.yaml", True),
("$platform.yaml", True),
("${a + b}.yaml", True), # Jinja expression
("device.yaml", False),
("path/to/device.yaml", False),
("my$file.yaml", True), # $file is a valid substitution
("price-100$.yaml", False), # $ at end, not followed by valid substitution
],
)
def test_include_file_has_unresolved_expressions(
tmp_path: Path, filename: str, expected: bool
) -> None:
"""has_unresolved_expressions() detects substitution patterns in the filename."""
parent = tmp_path / "main.yaml"
include = yaml_util.IncludeFile(parent, filename, None, lambda _: {})
assert include.has_unresolved_expressions() == expected
def test_include_in_list_context() -> None:
"""!include of a file returning a list is handled correctly,
including when that list itself contains a nested IncludeFile."""
parent = Path("/fake/main.yaml")
# The nested IncludeFile resolves to a plain string value
inner = yaml_util.IncludeFile(parent, "inner.yaml", None, lambda _: "gamma")
# The outer IncludeFile returns a list whose last element is itself an IncludeFile,
# exercising the substitution pass's ability to recurse into loaded content.
outer = yaml_util.IncludeFile(
parent, "items.yaml", None, lambda _: ["alpha", "beta", inner]
)
config = OrderedDict({"values": outer})
config = substitutions.do_substitution_pass(config)
assert config["values"] == ["alpha", "beta", "gamma"]
def test_include_plain_filename_loads_after_deferred_refactor() -> None:
"""!include with a plain filename (no $ expressions) still loads correctly.
Regression guard: the deferred-loading refactor must not break the simple case.
"""
parent = Path("/fake/main.yaml")
include = yaml_util.IncludeFile(
parent, "child.yaml", None, lambda _: {"answer": 42}
)
config = OrderedDict({"result": include})
config = substitutions.do_substitution_pass(config)
assert config["result"]["answer"] == 42
def test_yaml_merge_include_with_filename_substitution_raises() -> None:
"""<<: !include ${expr} raises a clear error — substitutions in merge-key filenames
are not yet supported, and the error message must say so."""
yaml_text = "base:\n existing: value\n <<: !include ${filename}.yaml\n"
with pytest.raises(EsphomeError, match="not supported yet"):
yaml_util.parse_yaml(
Path("/fake/main.yaml"), io.StringIO(yaml_text), lambda _: {}
)
def test_yaml_merge_list_include_with_filename_substitution_raises() -> None:
"""Substitutions in include filenames within merge-key lists raise a clear error."""
yaml_text = "base:\n existing: value\n <<:\n - !include ${filename}.yaml\n"
with pytest.raises(EsphomeError, match="not supported yet"):
yaml_util.parse_yaml(
Path("/fake/main.yaml"), io.StringIO(yaml_text), lambda _: {}
)
def test_yaml_merge_chain_include_resolves() -> None:
"""Chained includes in merge keys resolve through multiple IncludeFile layers."""
parent = Path("/fake/main.yaml")
inner = yaml_util.IncludeFile(parent, "inner.yaml", None, lambda _: {"x": 1})
outer = yaml_util.IncludeFile(parent, "outer.yaml", None, lambda _: inner)
yaml_text = "base:\n existing: value\n <<: !include outer.yaml\n"
config = yaml_util.parse_yaml(parent, io.StringIO(yaml_text), lambda _: outer)
config = substitutions.do_substitution_pass(config)
assert config["base"]["x"] == 1
assert config["base"]["existing"] == "value"
def test_yaml_merge_chain_include_depth_exceeded() -> None:
"""Chain includes in merge keys exceeding depth limit raise a clear error."""
parent = Path("/fake/main.yaml")
def self_referencing_loader(path: Path) -> yaml_util.IncludeFile:
return yaml_util.IncludeFile(parent, path.name, None, self_referencing_loader)
yaml_text = "base:\n <<: !include loop.yaml\n"
with pytest.raises(EsphomeError, match="Maximum include chain depth"):
yaml_util.parse_yaml(parent, io.StringIO(yaml_text), self_referencing_loader)