From bfcc0e26a367625df7db7165172a4f0846b39421 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 22:22:44 -1000 Subject: [PATCH 1/6] [dfrobot_sen0395][pipsolar][sim800l][wl_134] Replace sprintf with snprintf/buf_append_printf (#13301) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/dfrobot_sen0395/commands.h | 8 ++++---- esphome/components/pipsolar/output/pipsolar_output.cpp | 2 +- esphome/components/sim800l/sim800l.cpp | 5 +++-- esphome/components/wl_134/wl_134.cpp | 5 +++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/esphome/components/dfrobot_sen0395/commands.h b/esphome/components/dfrobot_sen0395/commands.h index cf3ba50be0..3b0551b184 100644 --- a/esphome/components/dfrobot_sen0395/commands.h +++ b/esphome/components/dfrobot_sen0395/commands.h @@ -75,8 +75,8 @@ class SetLatencyCommand : public Command { class SensorCfgStartCommand : public Command { public: SensorCfgStartCommand(bool startup_mode) : startup_mode_(startup_mode) { - char tmp_cmd[20] = {0}; - sprintf(tmp_cmd, "sensorCfgStart %d", startup_mode); + char tmp_cmd[20]; // "sensorCfgStart " (15) + "0/1" (1) + null = 17 + buf_append_printf(tmp_cmd, sizeof(tmp_cmd), 0, "sensorCfgStart %d", startup_mode); cmd_ = std::string(tmp_cmd); } uint8_t on_message(std::string &message) override; @@ -142,8 +142,8 @@ class SensitivityCommand : public Command { SensitivityCommand(uint8_t sensitivity) : sensitivity_(sensitivity) { if (sensitivity > 9) sensitivity_ = sensitivity = 9; - char tmp_cmd[20] = {0}; - sprintf(tmp_cmd, "setSensitivity %d", sensitivity); + char tmp_cmd[20]; // "setSensitivity " (15) + "0-9" (1) + null = 17 + buf_append_printf(tmp_cmd, sizeof(tmp_cmd), 0, "setSensitivity %d", sensitivity); cmd_ = std::string(tmp_cmd); }; uint8_t on_message(std::string &message) override; diff --git a/esphome/components/pipsolar/output/pipsolar_output.cpp b/esphome/components/pipsolar/output/pipsolar_output.cpp index ebfb9a7bbc..60f6342759 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.cpp +++ b/esphome/components/pipsolar/output/pipsolar_output.cpp @@ -8,7 +8,7 @@ namespace pipsolar { static const char *const TAG = "pipsolar.output"; void PipsolarOutput::write_state(float state) { - char tmp[10]; + char tmp[16]; snprintf(tmp, sizeof(tmp), this->set_command_, state); if (std::find(this->possible_values_.begin(), this->possible_values_.end(), state) != this->possible_values_.end()) { diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index e3edda0e72..251e18648b 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -1,4 +1,5 @@ #include "sim800l.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -50,8 +51,8 @@ void Sim800LComponent::update() { } else if (state_ == STATE_RECEIVED_SMS) { // Serial Buffer should have flushed. // Send cmd to delete received sms - char delete_cmd[20]; - sprintf(delete_cmd, "AT+CMGD=%d", this->parse_index_); + char delete_cmd[20]; // "AT+CMGD=" (8) + uint8_t (max 3) + null = 12 <= 20 + buf_append_printf(delete_cmd, sizeof(delete_cmd), 0, "AT+CMGD=%d", this->parse_index_); this->send_cmd_(delete_cmd); this->state_ = STATE_CHECK_SMS; this->expect_ack_ = true; diff --git a/esphome/components/wl_134/wl_134.cpp b/esphome/components/wl_134/wl_134.cpp index 20a145d183..a589f71c84 100644 --- a/esphome/components/wl_134/wl_134.cpp +++ b/esphome/components/wl_134/wl_134.cpp @@ -1,4 +1,5 @@ #include "wl_134.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -78,8 +79,8 @@ Wl134Component::Rfid134Error Wl134Component::read_packet_() { reading.id, reading.country, reading.isData ? "true" : "false", reading.isAnimal ? "true" : "false", reading.reserved0, reading.reserved1); - char buf[20]; - sprintf(buf, "%03d%012lld", reading.country, reading.id); + char buf[20]; // "%03d" (3) + "%012" PRId64 (12) + null = 16 max + buf_append_printf(buf, sizeof(buf), 0, "%03d%012" PRId64, reading.country, reading.id); this->publish_state(buf); if (this->do_reset_) { this->set_timeout(1000, [this]() { this->publish_state(""); }); From f8bd4ef57d67127914affeff02f1da6265f50c2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 22:22:57 -1000 Subject: [PATCH 2/6] [template][event] Use StringRef for set_action and on_event triggers (#13328) --- esphome/components/event/__init__.py | 4 +--- esphome/components/event/automation.h | 4 ++-- esphome/components/event/event.cpp | 4 ++-- esphome/components/event/event.h | 4 ++-- esphome/components/template/select/__init__.py | 2 +- esphome/components/template/select/template_select.cpp | 2 +- esphome/components/template/select/template_select.h | 5 +++-- 7 files changed, 12 insertions(+), 13 deletions(-) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index e2b69ba872..8fac7a279c 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -90,9 +90,7 @@ async def setup_event_core_(var, config, *, event_types: list[str]): for conf in config.get(CONF_ON_EVENT, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.std_string, "event_type")], conf - ) + await automation.build_automation(trigger, [(cg.StringRef, "event_type")], conf) cg.add(var.set_event_types(event_types)) diff --git a/esphome/components/event/automation.h b/esphome/components/event/automation.h index 5bdba18687..7730506c10 100644 --- a/esphome/components/event/automation.h +++ b/esphome/components/event/automation.h @@ -14,10 +14,10 @@ template class TriggerEventAction : public Action, public void play(const Ts &...x) override { this->parent_->trigger(this->event_type_.value(x...)); } }; -class EventTrigger : public Trigger { +class EventTrigger : public Trigger { public: EventTrigger(Event *event) { - event->add_on_event_callback([this](const std::string &event_type) { this->trigger(event_type); }); + event->add_on_event_callback([this](StringRef event_type) { this->trigger(event_type); }); } }; diff --git a/esphome/components/event/event.cpp b/esphome/components/event/event.cpp index 8015f2255a..667d4218f3 100644 --- a/esphome/components/event/event.cpp +++ b/esphome/components/event/event.cpp @@ -23,7 +23,7 @@ void Event::trigger(const std::string &event_type) { } this->last_event_type_ = found; ESP_LOGD(TAG, "'%s' >> '%s'", this->get_name().c_str(), this->last_event_type_); - this->event_callback_.call(event_type); + this->event_callback_.call(StringRef(found)); #if defined(USE_EVENT) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_event(this); #endif @@ -45,7 +45,7 @@ void Event::set_event_types(const std::vector &event_types) { this->last_event_type_ = nullptr; // Reset when types change } -void Event::add_on_event_callback(std::function &&callback) { +void Event::add_on_event_callback(std::function &&callback) { this->event_callback_.add(std::move(callback)); } diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index f77ad326d9..b5519a0520 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -70,10 +70,10 @@ class Event : public EntityBase, public EntityBase_DeviceClass { /// Check if an event has been triggered. bool has_event() const { return this->last_event_type_ != nullptr; } - void add_on_event_callback(std::function &&callback); + void add_on_event_callback(std::function &&callback); protected: - LazyCallbackManager event_callback_; + LazyCallbackManager event_callback_; FixedVector types_; private: diff --git a/esphome/components/template/select/__init__.py b/esphome/components/template/select/__init__.py index 0e9c240547..574f1f5fb7 100644 --- a/esphome/components/template/select/__init__.py +++ b/esphome/components/template/select/__init__.py @@ -88,5 +88,5 @@ async def to_code(config): if CONF_SET_ACTION in config: await automation.build_automation( - var.get_set_trigger(), [(cg.std_string, "x")], config[CONF_SET_ACTION] + var.get_set_trigger(), [(cg.StringRef, "x")], config[CONF_SET_ACTION] ) diff --git a/esphome/components/template/select/template_select.cpp b/esphome/components/template/select/template_select.cpp index 9d2df0956b..818abfc1d7 100644 --- a/esphome/components/template/select/template_select.cpp +++ b/esphome/components/template/select/template_select.cpp @@ -41,7 +41,7 @@ void TemplateSelect::update() { } void TemplateSelect::control(size_t index) { - this->set_trigger_->trigger(std::string(this->option_at(index))); + this->set_trigger_->trigger(StringRef(this->option_at(index))); if (this->optimistic_) this->publish_state(index); diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h index 2757c51405..114d25b9ce 100644 --- a/esphome/components/template/select/template_select.h +++ b/esphome/components/template/select/template_select.h @@ -4,6 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/preferences.h" +#include "esphome/core/string_ref.h" #include "esphome/core/template_lambda.h" namespace esphome::template_ { @@ -17,7 +18,7 @@ class TemplateSelect final : public select::Select, public PollingComponent { void dump_config() override; float get_setup_priority() const override { return setup_priority::HARDWARE; } - Trigger *get_set_trigger() const { return this->set_trigger_; } + Trigger *get_set_trigger() const { return this->set_trigger_; } void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_initial_option_index(size_t initial_option_index) { this->initial_option_index_ = initial_option_index; } void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; } @@ -27,7 +28,7 @@ class TemplateSelect final : public select::Select, public PollingComponent { bool optimistic_ = false; size_t initial_option_index_{0}; bool restore_value_ = false; - Trigger *set_trigger_ = new Trigger(); + Trigger *set_trigger_ = new Trigger(); TemplateLambda f_; ESPPreferenceObject pref_; From 892e9b006fa4c9fed028eeda51af08fbfa027e55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 23:57:27 -1000 Subject: [PATCH 3/6] [api] Use MAX_STATE_LEN constant for Home Assistant state buffer (#13278) --- esphome/components/api/api_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 25512de4c7..0364879ccd 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1715,7 +1715,7 @@ void APIConnection::on_home_assistant_state_response(const HomeAssistantStateRes // HA state max length is 255 characters, but attributes can be much longer // Use stack buffer for common case (states), heap fallback for large attributes size_t state_len = msg.state.size(); - SmallBufferWithHeapFallback<256> state_buf_alloc(state_len + 1); + SmallBufferWithHeapFallback state_buf_alloc(state_len + 1); char *state_buf = reinterpret_cast(state_buf_alloc.get()); if (state_len > 0) { memcpy(state_buf, msg.state.c_str(), state_len); From ee264d0fd444c74be13bdb683a1f49c70617762b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 23:57:42 -1000 Subject: [PATCH 4/6] [anova] Replace sprintf with bounds-checked alternatives (#13303) --- esphome/components/anova/anova_base.cpp | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/esphome/components/anova/anova_base.cpp b/esphome/components/anova/anova_base.cpp index ce4febbe37..fef4f1d852 100644 --- a/esphome/components/anova/anova_base.cpp +++ b/esphome/components/anova/anova_base.cpp @@ -18,31 +18,31 @@ AnovaPacket *AnovaCodec::clean_packet_() { AnovaPacket *AnovaCodec::get_read_device_status_request() { this->current_query_ = READ_DEVICE_STATUS; - sprintf((char *) this->packet_.data, "%s", CMD_READ_DEVICE_STATUS); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), "%s", CMD_READ_DEVICE_STATUS); return this->clean_packet_(); } AnovaPacket *AnovaCodec::get_read_target_temp_request() { this->current_query_ = READ_TARGET_TEMPERATURE; - sprintf((char *) this->packet_.data, "%s", CMD_READ_TARGET_TEMP); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), "%s", CMD_READ_TARGET_TEMP); return this->clean_packet_(); } AnovaPacket *AnovaCodec::get_read_current_temp_request() { this->current_query_ = READ_CURRENT_TEMPERATURE; - sprintf((char *) this->packet_.data, "%s", CMD_READ_CURRENT_TEMP); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), "%s", CMD_READ_CURRENT_TEMP); return this->clean_packet_(); } AnovaPacket *AnovaCodec::get_read_unit_request() { this->current_query_ = READ_UNIT; - sprintf((char *) this->packet_.data, "%s", CMD_READ_UNIT); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), "%s", CMD_READ_UNIT); return this->clean_packet_(); } AnovaPacket *AnovaCodec::get_read_data_request() { this->current_query_ = READ_DATA; - sprintf((char *) this->packet_.data, "%s", CMD_READ_DATA); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), "%s", CMD_READ_DATA); return this->clean_packet_(); } @@ -50,25 +50,25 @@ AnovaPacket *AnovaCodec::get_set_target_temp_request(float temperature) { this->current_query_ = SET_TARGET_TEMPERATURE; if (this->fahrenheit_) temperature = ctof(temperature); - sprintf((char *) this->packet_.data, CMD_SET_TARGET_TEMP, temperature); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), CMD_SET_TARGET_TEMP, temperature); return this->clean_packet_(); } AnovaPacket *AnovaCodec::get_set_unit_request(char unit) { this->current_query_ = SET_UNIT; - sprintf((char *) this->packet_.data, CMD_SET_TEMP_UNIT, unit); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), CMD_SET_TEMP_UNIT, unit); return this->clean_packet_(); } AnovaPacket *AnovaCodec::get_start_request() { this->current_query_ = START; - sprintf((char *) this->packet_.data, CMD_START); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), "%s", CMD_START); return this->clean_packet_(); } AnovaPacket *AnovaCodec::get_stop_request() { this->current_query_ = STOP; - sprintf((char *) this->packet_.data, CMD_STOP); + snprintf((char *) this->packet_.data, sizeof(this->packet_.data), "%s", CMD_STOP); return this->clean_packet_(); } From a0d3d54d69a9f76eec891dbfb125083f533f1e25 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 20 Jan 2026 05:13:36 +1100 Subject: [PATCH 5/6] [mipi_spi] Add variants of ESP32-2432S028 displays (#13340) --- esphome/components/mipi_spi/mipi_spi.h | 9 ++-- .../components/mipi_spi/models/adafruit.py | 2 - esphome/components/mipi_spi/models/amoled.py | 3 -- esphome/components/mipi_spi/models/cyd.py | 43 +++++++++++++++++-- esphome/components/mipi_spi/models/ili.py | 30 ++++++++++++- esphome/components/mipi_spi/models/jc.py | 2 - esphome/components/mipi_spi/models/lanbon.py | 2 - esphome/components/mipi_spi/models/lilygo.py | 2 - 8 files changed, 70 insertions(+), 23 deletions(-) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index a59cb8104b..fd5bc97596 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -224,12 +224,9 @@ class MipiSpi : public display::Display, this->madctl_ & MADCTL_BGR ? "BGR" : "RGB", DISPLAYPIXEL * 8, IS_BIG_ENDIAN ? "Big" : "Little"); if (this->brightness_.has_value()) esph_log_config(TAG, " Brightness: %u", this->brightness_.value()); - if (this->cs_ != nullptr) - esph_log_config(TAG, " CS Pin: %s", this->cs_->dump_summary().c_str()); - if (this->reset_pin_ != nullptr) - esph_log_config(TAG, " Reset Pin: %s", this->reset_pin_->dump_summary().c_str()); - if (this->dc_pin_ != nullptr) - esph_log_config(TAG, " DC Pin: %s", this->dc_pin_->dump_summary().c_str()); + log_pin(TAG, " CS Pin: ", this->cs_); + log_pin(TAG, " Reset Pin: ", this->reset_pin_); + log_pin(TAG, " DC Pin: ", this->dc_pin_); esph_log_config(TAG, " SPI Mode: %d\n" " SPI Data rate: %dMHz\n" diff --git a/esphome/components/mipi_spi/models/adafruit.py b/esphome/components/mipi_spi/models/adafruit.py index 0e91107bee..26790b1493 100644 --- a/esphome/components/mipi_spi/models/adafruit.py +++ b/esphome/components/mipi_spi/models/adafruit.py @@ -26,5 +26,3 @@ ST7789V.extend( reset_pin=40, invert_colors=True, ) - -models = {} diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index 4d6c8da4b0..32cad70ac0 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -105,6 +105,3 @@ CO5300 = DriverChip( (WCE, 0x00), ), ) - - -models = {} diff --git a/esphome/components/mipi_spi/models/cyd.py b/esphome/components/mipi_spi/models/cyd.py index a25ecf33a8..7229412f18 100644 --- a/esphome/components/mipi_spi/models/cyd.py +++ b/esphome/components/mipi_spi/models/cyd.py @@ -1,10 +1,45 @@ -from .ili import ILI9341 +from .ili import ILI9341, ILI9342, ST7789V ILI9341.extend( + # ESP32-2432S028 CYD board with Micro USB, has ILI9341 controller "ESP32-2432S028", data_rate="40MHz", - cs_pin=15, - dc_pin=2, + cs_pin={"number": 15, "ignore_strapping_warning": True}, + dc_pin={"number": 2, "ignore_strapping_warning": True}, ) -models = {} +ST7789V.extend( + # ESP32-2432S028 CYD board with USB C + Micro USB, has ST7789V controller + "ESP32-2432S028-7789", + data_rate="40MHz", + cs_pin={"number": 15, "ignore_strapping_warning": True}, + dc_pin={"number": 2, "ignore_strapping_warning": True}, +) + +# fmt: off + +ILI9342.extend( + # ESP32-2432S028 CYD board with USB C + Micro USB, has ILI9342 controller + "ESP32-2432S028-9342", + data_rate="40MHz", + cs_pin={"number": 15, "ignore_strapping_warning": True}, + dc_pin={"number": 2, "ignore_strapping_warning": True}, + initsequence=( + (0xCB, 0x39, 0x2C, 0x00, 0x34, 0x02), # Power Control A + (0xCF, 0x00, 0xC1, 0x30), # Power Control B + (0xE8, 0x85, 0x00, 0x78), # Driver timing control A + (0xEA, 0x00, 0x00), # Driver timing control B + (0xED, 0x64, 0x03, 0x12, 0x81), # Power on sequence control + (0xF7, 0x20), # Pump ratio control + (0xC0, 0x23), # Power Control 1 + (0xC1, 0x10), # Power Control 2 + (0xC5, 0x3E, 0x28), # VCOM Control 1 + (0xC7, 0x86), # VCOM Control 2 + (0xB1, 0x00, 0x1B), # Frame Rate Control + (0xB6, 0x0A, 0xA2, 0x27, 0x00), # Display Function Control + (0xF2, 0x00), # Enable 3G + (0x26, 0x01), # Gamma Set + (0xE0, 0x00, 0x0C, 0x11, 0x04, 0x11, 0x08, 0x37, 0x89, 0x4C, 0x06, 0x0C, 0x0A, 0x2E, 0x34, 0x0F), # Positive Gamma Correction + (0xE1, 0x00, 0x0B, 0x11, 0x05, 0x13, 0x09, 0x33, 0x67, 0x48, 0x07, 0x0E, 0x0B, 0x23, 0x33, 0x0F), # Negative Gamma Correction + ) +) diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 60a25c32a9..6b672b0859 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -148,6 +148,34 @@ ILI9341 = DriverChip( ), ), ) + +# fmt: off + +ILI9342 = DriverChip( + "ILI9342", + width=320, + height=240, + mirror_x=True, + initsequence=( + (0xCB, 0x39, 0x2C, 0x00, 0x34, 0x02), # Power Control A + (0xCF, 0x00, 0xC1, 0x30), # Power Control B + (0xE8, 0x85, 0x00, 0x78), # Driver timing control A + (0xEA, 0x00, 0x00), # Driver timing control B + (0xED, 0x64, 0x03, 0x12, 0x81), # Power on sequence control + (0xF7, 0x20), # Pump ratio control + (0xC0, 0x23), # Power Control 1 + (0xC1, 0x10), # Power Control 2 + (0xC5, 0x3E, 0x28), # VCOM Control 1 + (0xC7, 0x86), # VCOM Control 2 + (0xB1, 0x00, 0x1B), # Frame Rate Control + (0xB6, 0x0A, 0xA2, 0x27, 0x00), # Display Function Control + (0xF2, 0x00), # Enable 3G + (0x26, 0x01), # Gamma Set + (0xE0, 0x0F, 0x1F, 0x1C, 0x0C, 0x0F, 0x08, 0x48, 0x98, 0x37, 0x0A, 0x13, 0x04, 0x11, 0x0D, 0x00), # Positive Gamma + (0xE1, 0x0F, 0x32, 0x2E, 0x0B, 0x0D, 0x05, 0x47, 0x75, 0x37, 0x06, 0x10, 0x03, 0x24, 0x20, 0x00), # Negative Gamma + ), +) + # M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation ILI9341.extend( "M5CORE2", @@ -758,5 +786,3 @@ ST7796.extend( dc_pin=0, invert_colors=True, ) - -models = {} diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index 5b936fd956..854814f572 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -588,5 +588,3 @@ DriverChip( (0x29, 0x00), ), ) - -models = {} diff --git a/esphome/components/mipi_spi/models/lanbon.py b/esphome/components/mipi_spi/models/lanbon.py index 6f9aa58674..8cec3c8317 100644 --- a/esphome/components/mipi_spi/models/lanbon.py +++ b/esphome/components/mipi_spi/models/lanbon.py @@ -11,5 +11,3 @@ ST7789V.extend( dc_pin=21, reset_pin=18, ) - -models = {} diff --git a/esphome/components/mipi_spi/models/lilygo.py b/esphome/components/mipi_spi/models/lilygo.py index 13ddc67465..46ec809029 100644 --- a/esphome/components/mipi_spi/models/lilygo.py +++ b/esphome/components/mipi_spi/models/lilygo.py @@ -56,5 +56,3 @@ ST7796.extend( backlight_pin=48, invert_colors=True, ) - -models = {} From 1996bc425f27de225d2b9baf7b394c07ed3ac657 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:46:24 -0500 Subject: [PATCH 6/6] [core] Fix state leakage and module caching when processing multiple configurations (#13368) Co-authored-by: Claude Opus 4.5 --- esphome/__main__.py | 112 ++++++++++++++++++++++------------ tests/unit_tests/test_main.py | 66 +++++++++++++++++++- 2 files changed, 139 insertions(+), 39 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 545464be10..09d2855eb1 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1,5 +1,6 @@ # PYTHON_ARGCOMPLETE_OK import argparse +from collections.abc import Callable from datetime import datetime import functools import getpass @@ -936,11 +937,21 @@ def command_dashboard(args: ArgsProtocol) -> int | None: return dashboard.start_dashboard(args) -def command_update_all(args: ArgsProtocol) -> int | None: +def run_multiple_configs( + files: list, command_builder: Callable[[str], list[str]] +) -> int: + """Run a command for each configuration file in a subprocess. + + Args: + files: List of configuration files to process. + command_builder: Callable that takes a file path and returns a command list. + + Returns: + Number of failed files. + """ import click success = {} - files = list_yaml_files(args.configuration) twidth = 60 def print_bar(middle_text): @@ -950,17 +961,19 @@ def command_update_all(args: ArgsProtocol) -> int | None: safe_print(f"{half_line}{middle_text}{half_line}") for f in files: - safe_print(f"Updating {color(AnsiFore.CYAN, str(f))}") + f_path = Path(f) if not isinstance(f, Path) else f + + if any(f_path.name == x for x in SECRETS_FILES): + _LOGGER.warning("Skipping secrets file %s", f_path) + continue + + safe_print(f"Processing {color(AnsiFore.CYAN, str(f))}") safe_print("-" * twidth) safe_print() - if CORE.dashboard: - rc = run_external_process( - "esphome", "--dashboard", "run", f, "--no-logs", "--device", "OTA" - ) - else: - rc = run_external_process( - "esphome", "run", f, "--no-logs", "--device", "OTA" - ) + + cmd = command_builder(f) + rc = run_external_process(*cmd) + if rc == 0: print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}") success[f] = True @@ -975,6 +988,8 @@ def command_update_all(args: ArgsProtocol) -> int | None: print_bar(f"[{color(AnsiFore.BOLD_WHITE, 'SUMMARY')}]") failed = 0 for f in files: + if f not in success: + continue # Skipped file if success[f]: safe_print(f" - {str(f)}: {color(AnsiFore.GREEN, 'SUCCESS')}") else: @@ -983,6 +998,17 @@ def command_update_all(args: ArgsProtocol) -> int | None: return failed +def command_update_all(args: ArgsProtocol) -> int | None: + files = list_yaml_files(args.configuration) + + def build_command(f): + if CORE.dashboard: + return ["esphome", "--dashboard", "run", f, "--no-logs", "--device", "OTA"] + return ["esphome", "run", f, "--no-logs", "--device", "OTA"] + + return run_multiple_configs(files, build_command) + + def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json @@ -1533,38 +1559,48 @@ def run_esphome(argv): _LOGGER.info("ESPHome %s", const.__version__) - for conf_path in args.configuration: - conf_path = Path(conf_path) - if any(conf_path.name == x for x in SECRETS_FILES): - _LOGGER.warning("Skipping secrets file %s", conf_path) - continue + # Multiple configurations: use subprocesses to avoid state leakage + # between compilations (e.g., LVGL touchscreen state in module globals) + if len(args.configuration) > 1: + # Build command by reusing argv, replacing all configs with single file + # argv[0] is the program path, skip it since we prefix with "esphome" + def build_command(f): + return ( + ["esphome"] + + [arg for arg in argv[1:] if arg not in args.configuration] + + [str(f)] + ) - CORE.config_path = conf_path - CORE.dashboard = args.dashboard + return run_multiple_configs(args.configuration, build_command) - # For logs command, skip updating external components - skip_external = args.command == "logs" - config = read_config( - dict(args.substitution) if args.substitution else {}, - skip_external_update=skip_external, - ) - if config is None: - return 2 - CORE.config = config + # Single configuration + conf_path = Path(args.configuration[0]) + if any(conf_path.name == x for x in SECRETS_FILES): + _LOGGER.warning("Skipping secrets file %s", conf_path) + return 0 - if args.command not in POST_CONFIG_ACTIONS: - safe_print(f"Unknown command {args.command}") + CORE.config_path = conf_path + CORE.dashboard = args.dashboard - try: - rc = POST_CONFIG_ACTIONS[args.command](args, config) - except EsphomeError as e: - _LOGGER.error(e, exc_info=args.verbose) - return 1 - if rc != 0: - return rc + # For logs command, skip updating external components + skip_external = args.command == "logs" + config = read_config( + dict(args.substitution) if args.substitution else {}, + skip_external_update=skip_external, + ) + if config is None: + return 2 + CORE.config = config - CORE.reset() - return 0 + if args.command not in POST_CONFIG_ACTIONS: + safe_print(f"Unknown command {args.command}") + return 1 + + try: + return POST_CONFIG_ACTIONS[args.command](args, config) + except EsphomeError as e: + _LOGGER.error(e, exc_info=args.verbose) + return 1 def main(): diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index fd8f04ded5..3268f7ee87 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -34,6 +34,7 @@ from esphome.__main__ import ( has_non_ip_address, has_resolvable_address, mqtt_get_ip, + run_esphome, run_miniterm, show_logs, upload_program, @@ -1988,7 +1989,7 @@ esp32: clean_output = strip_ansi_codes(captured.out) assert "test-device_123.yaml" in clean_output - assert "Updating" in clean_output + assert "Processing" in clean_output assert "SUCCESS" in clean_output assert "SUMMARY" in clean_output @@ -3172,3 +3173,66 @@ def test_run_miniterm_buffer_limit_prevents_unbounded_growth() -> None: x_count = printed_line.count("X") assert x_count < 150, f"Expected truncation but got {x_count} X's" assert x_count == 95, f"Expected 95 X's after truncation but got {x_count}" + + +def test_run_esphome_multiple_configs_with_secrets( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test run_esphome with multiple configs and secrets file. + + Verifies: + - Multiple configs use subprocess isolation + - Secrets files are skipped with warning + - Secrets files don't appear in summary + """ + # Create two config files and a secrets file + yaml_file1 = tmp_path / "device1.yaml" + yaml_file1.write_text(""" +esphome: + name: device1 + +esp32: + board: nodemcu-32s +""") + yaml_file2 = tmp_path / "device2.yaml" + yaml_file2.write_text(""" +esphome: + name: device2 + +esp32: + board: nodemcu-32s +""") + secrets_file = tmp_path / "secrets.yaml" + secrets_file.write_text("wifi_password: secret123\n") + + setup_core(tmp_path=tmp_path) + mock_run_external_process.return_value = 0 + + # run_esphome expects argv[0] to be the program name (gets sliced off by parse_args) + with caplog.at_level(logging.WARNING): + result = run_esphome( + ["esphome", "compile", str(yaml_file1), str(secrets_file), str(yaml_file2)] + ) + + assert result == 0 + + # Check secrets file was skipped with warning + assert "Skipping secrets file" in caplog.text + assert "secrets.yaml" in caplog.text + + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # Both config files should be processed + assert "device1.yaml" in clean_output + assert "device2.yaml" in clean_output + assert "SUMMARY" in clean_output + + # Secrets should not appear in summary + summary_section = ( + clean_output.split("SUMMARY")[1] if "SUMMARY" in clean_output else "" + ) + assert "secrets.yaml" not in summary_section