mirror of
https://github.com/esphome/esphome.git
synced 2026-08-31 01:56:01 +00:00
Merge remote-tracking branch 'upstream/templatable-value-specialize' into integration
This commit is contained in:
+1
-1
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_();
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 " : "");
|
||||
|
||||
@@ -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<> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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,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
|
||||
@@ -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}
|
||||
@@ -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."""
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user