From 1a6c67f92ef4d46ed4134d2e16a102e96d5159a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 13:45:03 -0600 Subject: [PATCH 1/9] [ssd1306_base] Move switch tables to PROGMEM with lookup tables (#13814) --- .../components/ssd1306_base/ssd1306_base.cpp | 136 ++++++++---------- .../components/ssd1306_base/ssd1306_base.h | 5 +- .../components/ssd1306_i2c/ssd1306_i2c.cpp | 2 +- .../components/ssd1306_spi/ssd1306_spi.cpp | 2 +- 4 files changed, 65 insertions(+), 80 deletions(-) diff --git a/esphome/components/ssd1306_base/ssd1306_base.cpp b/esphome/components/ssd1306_base/ssd1306_base.cpp index e0e7f94ce0..be99bd93da 100644 --- a/esphome/components/ssd1306_base/ssd1306_base.cpp +++ b/esphome/components/ssd1306_base/ssd1306_base.cpp @@ -1,6 +1,7 @@ #include "ssd1306_base.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" namespace esphome { namespace ssd1306_base { @@ -40,6 +41,55 @@ static const uint8_t SSD1305_COMMAND_SET_AREA_COLOR = 0xD8; static const uint8_t SH1107_COMMAND_SET_START_LINE = 0xDC; static const uint8_t SH1107_COMMAND_CHARGE_PUMP = 0xAD; +// Verify first enum value and table sizes match SSD1306_MODEL_COUNT +static_assert(SSD1306_MODEL_128_32 == 0, "SSD1306Model enum must start at 0"); + +// PROGMEM lookup table indexed by SSD1306Model enum (width, height per model) +struct ModelDimensions { + uint8_t width; + uint8_t height; +}; +static const ModelDimensions MODEL_DIMS[] PROGMEM = { + {128, 32}, // SSD1306_MODEL_128_32 + {128, 64}, // SSD1306_MODEL_128_64 + {96, 16}, // SSD1306_MODEL_96_16 + {64, 48}, // SSD1306_MODEL_64_48 + {64, 32}, // SSD1306_MODEL_64_32 + {72, 40}, // SSD1306_MODEL_72_40 + {128, 32}, // SH1106_MODEL_128_32 + {128, 64}, // SH1106_MODEL_128_64 + {96, 16}, // SH1106_MODEL_96_16 + {64, 48}, // SH1106_MODEL_64_48 + {64, 128}, // SH1107_MODEL_128_64 (note: width is 64, height is 128) + {128, 128}, // SH1107_MODEL_128_128 + {128, 32}, // SSD1305_MODEL_128_32 + {128, 64}, // SSD1305_MODEL_128_64 +}; + +// clang-format off +PROGMEM_STRING_TABLE(ModelStrings, + "SSD1306 128x32", // SSD1306_MODEL_128_32 + "SSD1306 128x64", // SSD1306_MODEL_128_64 + "SSD1306 96x16", // SSD1306_MODEL_96_16 + "SSD1306 64x48", // SSD1306_MODEL_64_48 + "SSD1306 64x32", // SSD1306_MODEL_64_32 + "SSD1306 72x40", // SSD1306_MODEL_72_40 + "SH1106 128x32", // SH1106_MODEL_128_32 + "SH1106 128x64", // SH1106_MODEL_128_64 + "SH1106 96x16", // SH1106_MODEL_96_16 + "SH1106 64x48", // SH1106_MODEL_64_48 + "SH1107 128x64", // SH1107_MODEL_128_64 + "SH1107 128x128", // SH1107_MODEL_128_128 + "SSD1305 128x32", // SSD1305_MODEL_128_32 + "SSD1305 128x64", // SSD1305_MODEL_128_64 + "Unknown" // fallback +); +// clang-format on +static_assert(sizeof(MODEL_DIMS) / sizeof(MODEL_DIMS[0]) == SSD1306_MODEL_COUNT, + "MODEL_DIMS must have one entry per SSD1306Model"); +static_assert(ModelStrings::COUNT == SSD1306_MODEL_COUNT + 1, + "ModelStrings must have one entry per SSD1306Model plus fallback"); + void SSD1306::setup() { this->init_internal_(this->get_buffer_length_()); @@ -146,6 +196,7 @@ void SSD1306::setup() { break; case SH1107_MODEL_128_64: case SH1107_MODEL_128_128: + case SSD1306_MODEL_COUNT: // Not used, but prevents build warning break; } @@ -274,54 +325,14 @@ void SSD1306::turn_off() { this->is_on_ = false; } int SSD1306::get_height_internal() { - switch (this->model_) { - case SH1107_MODEL_128_64: - case SH1107_MODEL_128_128: - return 128; - case SSD1306_MODEL_128_32: - case SSD1306_MODEL_64_32: - case SH1106_MODEL_128_32: - case SSD1305_MODEL_128_32: - return 32; - case SSD1306_MODEL_128_64: - case SH1106_MODEL_128_64: - case SSD1305_MODEL_128_64: - return 64; - case SSD1306_MODEL_96_16: - case SH1106_MODEL_96_16: - return 16; - case SSD1306_MODEL_64_48: - case SH1106_MODEL_64_48: - return 48; - case SSD1306_MODEL_72_40: - return 40; - default: - return 0; - } + if (this->model_ >= SSD1306_MODEL_COUNT) + return 0; + return progmem_read_byte(&MODEL_DIMS[this->model_].height); } int SSD1306::get_width_internal() { - switch (this->model_) { - case SSD1306_MODEL_128_32: - case SH1106_MODEL_128_32: - case SSD1306_MODEL_128_64: - case SH1106_MODEL_128_64: - case SSD1305_MODEL_128_32: - case SSD1305_MODEL_128_64: - case SH1107_MODEL_128_128: - return 128; - case SSD1306_MODEL_96_16: - case SH1106_MODEL_96_16: - return 96; - case SSD1306_MODEL_64_48: - case SSD1306_MODEL_64_32: - case SH1106_MODEL_64_48: - case SH1107_MODEL_128_64: - return 64; - case SSD1306_MODEL_72_40: - return 72; - default: - return 0; - } + if (this->model_ >= SSD1306_MODEL_COUNT) + return 0; + return progmem_read_byte(&MODEL_DIMS[this->model_].width); } size_t SSD1306::get_buffer_length_() { return size_t(this->get_width_internal()) * size_t(this->get_height_internal()) / 8u; @@ -361,37 +372,8 @@ void SSD1306::init_reset_() { this->reset_pin_->digital_write(true); } } -const char *SSD1306::model_str_() { - switch (this->model_) { - case SSD1306_MODEL_128_32: - return "SSD1306 128x32"; - case SSD1306_MODEL_128_64: - return "SSD1306 128x64"; - case SSD1306_MODEL_64_32: - return "SSD1306 64x32"; - case SSD1306_MODEL_96_16: - return "SSD1306 96x16"; - case SSD1306_MODEL_64_48: - return "SSD1306 64x48"; - case SSD1306_MODEL_72_40: - return "SSD1306 72x40"; - case SH1106_MODEL_128_32: - return "SH1106 128x32"; - case SH1106_MODEL_128_64: - return "SH1106 128x64"; - case SH1106_MODEL_96_16: - return "SH1106 96x16"; - case SH1106_MODEL_64_48: - return "SH1106 64x48"; - case SH1107_MODEL_128_64: - return "SH1107 128x64"; - case SSD1305_MODEL_128_32: - return "SSD1305 128x32"; - case SSD1305_MODEL_128_64: - return "SSD1305 128x64"; - default: - return "Unknown"; - } +const LogString *SSD1306::model_str_() { + return ModelStrings::get_log_str(static_cast(this->model_), ModelStrings::LAST_INDEX); } } // namespace ssd1306_base diff --git a/esphome/components/ssd1306_base/ssd1306_base.h b/esphome/components/ssd1306_base/ssd1306_base.h index a573437386..3cc795a323 100644 --- a/esphome/components/ssd1306_base/ssd1306_base.h +++ b/esphome/components/ssd1306_base/ssd1306_base.h @@ -22,6 +22,9 @@ enum SSD1306Model { SH1107_MODEL_128_128, SSD1305_MODEL_128_32, SSD1305_MODEL_128_64, + // When adding a new model, add it before SSD1306_MODEL_COUNT and update + // MODEL_DIMS and ModelStrings tables in ssd1306_base.cpp + SSD1306_MODEL_COUNT, // must be last }; class SSD1306 : public display::DisplayBuffer { @@ -70,7 +73,7 @@ class SSD1306 : public display::DisplayBuffer { int get_height_internal() override; int get_width_internal() override; size_t get_buffer_length_(); - const char *model_str_(); + const LogString *model_str_(); SSD1306Model model_{SSD1306_MODEL_128_64}; GPIOPin *reset_pin_{nullptr}; diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp index 47a21a8ff4..e1f6e91243 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp @@ -28,7 +28,7 @@ void I2CSSD1306::dump_config() { " Offset X: %d\n" " Offset Y: %d\n" " Inverted Color: %s", - this->model_str_(), YESNO(this->external_vcc_), YESNO(this->flip_x_), YESNO(this->flip_y_), + LOG_STR_ARG(this->model_str_()), YESNO(this->external_vcc_), YESNO(this->flip_x_), YESNO(this->flip_y_), this->offset_x_, this->offset_y_, YESNO(this->invert_)); LOG_I2C_DEVICE(this); LOG_PIN(" Reset Pin: ", this->reset_pin_); diff --git a/esphome/components/ssd1306_spi/ssd1306_spi.cpp b/esphome/components/ssd1306_spi/ssd1306_spi.cpp index db28dfc564..af9a17c8ab 100644 --- a/esphome/components/ssd1306_spi/ssd1306_spi.cpp +++ b/esphome/components/ssd1306_spi/ssd1306_spi.cpp @@ -24,7 +24,7 @@ void SPISSD1306::dump_config() { " Offset X: %d\n" " Offset Y: %d\n" " Inverted Color: %s", - this->model_str_(), YESNO(this->external_vcc_), YESNO(this->flip_x_), YESNO(this->flip_y_), + LOG_STR_ARG(this->model_str_()), YESNO(this->external_vcc_), YESNO(this->flip_x_), YESNO(this->flip_y_), this->offset_x_, this->offset_y_, YESNO(this->invert_)); LOG_PIN(" CS Pin: ", this->cs_); LOG_PIN(" DC Pin: ", this->dc_pin_); From c03abcdb861da71e2f51c596e8f46179a2124d87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 13:53:53 -0600 Subject: [PATCH 2/9] [http_request] Reduce heap allocations in update check by parsing JSON directly from buffer (#13588) --- .../update/http_request_update.cpp | 24 +++++++------------ esphome/components/json/json_util.cpp | 7 +++++- esphome/components/json/json_util.h | 2 ++ 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index c63e55d159..85609bd31f 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -90,16 +90,14 @@ void HttpRequestUpdate::update_task(void *params) { UPDATE_RETURN; } size_t read_index = container->get_bytes_read(); + size_t content_length = container->content_length; + + container->end(); + container.reset(); // Release ownership of the container's shared_ptr bool valid = false; - { // Ensures the response string falls out of scope and deallocates before the task ends - std::string response((char *) data, read_index); - allocator.deallocate(data, container->content_length); - - container->end(); - container.reset(); // Release ownership of the container's shared_ptr - - valid = json::parse_json(response, [this_update](JsonObject root) -> bool { + { // Scope to ensure JsonDocument is destroyed before deallocating buffer + valid = json::parse_json(data, read_index, [this_update](JsonObject root) -> bool { if (!root[ESPHOME_F("name")].is() || !root[ESPHOME_F("version")].is() || !root[ESPHOME_F("builds")].is()) { ESP_LOGE(TAG, "Manifest does not contain required fields"); @@ -137,6 +135,7 @@ void HttpRequestUpdate::update_task(void *params) { return false; }); } + allocator.deallocate(data, content_length); if (!valid) { ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_.c_str()); @@ -157,17 +156,12 @@ void HttpRequestUpdate::update_task(void *params) { } } - { // Ensures the current version string falls out of scope and deallocates before the task ends - std::string current_version; #ifdef ESPHOME_PROJECT_VERSION - current_version = ESPHOME_PROJECT_VERSION; + this_update->update_info_.current_version = ESPHOME_PROJECT_VERSION; #else - current_version = ESPHOME_VERSION; + this_update->update_info_.current_version = ESPHOME_VERSION; #endif - this_update->update_info_.current_version = current_version; - } - bool trigger_update_available = false; if (this_update->update_info_.latest_version.empty() || diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 869d29f92e..69f8bfc61a 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -25,8 +25,13 @@ std::string build_json(const json_build_t &f) { } bool parse_json(const std::string &data, const json_parse_t &f) { + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson + return parse_json(reinterpret_cast(data.c_str()), data.size(), f); +} + +bool parse_json(const uint8_t *data, size_t len, const json_parse_t &f) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - JsonDocument doc = parse_json(reinterpret_cast(data.c_str()), data.size()); + JsonDocument doc = parse_json(data, len); if (doc.overflowed() || doc.isNull()) return false; return f(doc.as()); diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 91cc84dc14..ca074926bd 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -50,6 +50,8 @@ std::string build_json(const json_build_t &f); /// Parse a JSON string and run the provided json parse function if it's valid. bool parse_json(const std::string &data, const json_parse_t &f); +/// Parse JSON from raw bytes and run the provided json parse function if it's valid. +bool parse_json(const uint8_t *data, size_t len, const json_parse_t &f); /// Parse a JSON string and return the root JsonDocument (or an unbound object on error) JsonDocument parse_json(const uint8_t *data, size_t len); From 727bb27611e185fecaf83bd3945ac8aebf420a55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 13:54:07 -0600 Subject: [PATCH 3/9] [bmp3xx_base/bmp581_base] Convert oversampling and IIR filter strings to PROGMEM_STRING_TABLE (#13808) --- .../components/bmp3xx_base/bmp3xx_base.cpp | 47 ++++------------- .../components/bmp581_base/bmp581_base.cpp | 51 ++++--------------- 2 files changed, 20 insertions(+), 78 deletions(-) diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.cpp b/esphome/components/bmp3xx_base/bmp3xx_base.cpp index d7d9972170..c781252de3 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.cpp +++ b/esphome/components/bmp3xx_base/bmp3xx_base.cpp @@ -6,8 +6,9 @@ */ #include "bmp3xx_base.h" -#include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include namespace esphome { @@ -26,46 +27,18 @@ static const LogString *chip_type_to_str(uint8_t chip_type) { } } +// Oversampling strings indexed by Oversampling enum (0-5): NONE, X2, X4, X8, X16, X32 +PROGMEM_STRING_TABLE(OversamplingStrings, "None", "2x", "4x", "8x", "16x", "32x", ""); + static const LogString *oversampling_to_str(Oversampling oversampling) { - switch (oversampling) { - case Oversampling::OVERSAMPLING_NONE: - return LOG_STR("None"); - case Oversampling::OVERSAMPLING_X2: - return LOG_STR("2x"); - case Oversampling::OVERSAMPLING_X4: - return LOG_STR("4x"); - case Oversampling::OVERSAMPLING_X8: - return LOG_STR("8x"); - case Oversampling::OVERSAMPLING_X16: - return LOG_STR("16x"); - case Oversampling::OVERSAMPLING_X32: - return LOG_STR("32x"); - default: - return LOG_STR(""); - } + return OversamplingStrings::get_log_str(static_cast(oversampling), OversamplingStrings::LAST_INDEX); } +// IIR filter strings indexed by IIRFilter enum (0-7): OFF, 2, 4, 8, 16, 32, 64, 128 +PROGMEM_STRING_TABLE(IIRFilterStrings, "OFF", "2x", "4x", "8x", "16x", "32x", "64x", "128x", ""); + static const LogString *iir_filter_to_str(IIRFilter filter) { - switch (filter) { - case IIRFilter::IIR_FILTER_OFF: - return LOG_STR("OFF"); - case IIRFilter::IIR_FILTER_2: - return LOG_STR("2x"); - case IIRFilter::IIR_FILTER_4: - return LOG_STR("4x"); - case IIRFilter::IIR_FILTER_8: - return LOG_STR("8x"); - case IIRFilter::IIR_FILTER_16: - return LOG_STR("16x"); - case IIRFilter::IIR_FILTER_32: - return LOG_STR("32x"); - case IIRFilter::IIR_FILTER_64: - return LOG_STR("64x"); - case IIRFilter::IIR_FILTER_128: - return LOG_STR("128x"); - default: - return LOG_STR(""); - } + return IIRFilterStrings::get_log_str(static_cast(filter), IIRFilterStrings::LAST_INDEX); } void BMP3XXComponent::setup() { diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index 67c6771862..c4a96ebc39 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -11,57 +11,26 @@ */ #include "bmp581_base.h" -#include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" namespace esphome::bmp581_base { static const char *const TAG = "bmp581"; +// Oversampling strings indexed by Oversampling enum (0-7): NONE, X2, X4, X8, X16, X32, X64, X128 +PROGMEM_STRING_TABLE(OversamplingStrings, "None", "2x", "4x", "8x", "16x", "32x", "64x", "128x", ""); + static const LogString *oversampling_to_str(Oversampling oversampling) { - switch (oversampling) { - case Oversampling::OVERSAMPLING_NONE: - return LOG_STR("None"); - case Oversampling::OVERSAMPLING_X2: - return LOG_STR("2x"); - case Oversampling::OVERSAMPLING_X4: - return LOG_STR("4x"); - case Oversampling::OVERSAMPLING_X8: - return LOG_STR("8x"); - case Oversampling::OVERSAMPLING_X16: - return LOG_STR("16x"); - case Oversampling::OVERSAMPLING_X32: - return LOG_STR("32x"); - case Oversampling::OVERSAMPLING_X64: - return LOG_STR("64x"); - case Oversampling::OVERSAMPLING_X128: - return LOG_STR("128x"); - default: - return LOG_STR(""); - } + return OversamplingStrings::get_log_str(static_cast(oversampling), OversamplingStrings::LAST_INDEX); } +// IIR filter strings indexed by IIRFilter enum (0-7): OFF, 2, 4, 8, 16, 32, 64, 128 +PROGMEM_STRING_TABLE(IIRFilterStrings, "OFF", "2x", "4x", "8x", "16x", "32x", "64x", "128x", ""); + static const LogString *iir_filter_to_str(IIRFilter filter) { - switch (filter) { - case IIRFilter::IIR_FILTER_OFF: - return LOG_STR("OFF"); - case IIRFilter::IIR_FILTER_2: - return LOG_STR("2x"); - case IIRFilter::IIR_FILTER_4: - return LOG_STR("4x"); - case IIRFilter::IIR_FILTER_8: - return LOG_STR("8x"); - case IIRFilter::IIR_FILTER_16: - return LOG_STR("16x"); - case IIRFilter::IIR_FILTER_32: - return LOG_STR("32x"); - case IIRFilter::IIR_FILTER_64: - return LOG_STR("64x"); - case IIRFilter::IIR_FILTER_128: - return LOG_STR("128x"); - default: - return LOG_STR(""); - } + return IIRFilterStrings::get_log_str(static_cast(filter), IIRFilterStrings::LAST_INDEX); } void BMP581Component::dump_config() { From 2a6d9d632505b5ccfef1678bdfe69f30c9d9b8df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 13:54:22 -0600 Subject: [PATCH 4/9] [mqtt] Avoid heap allocation in on_log by using const char* publish overload (#13809) --- esphome/components/mqtt/mqtt_client.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index d503461257..90b423c386 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -170,10 +170,8 @@ void MQTTClientComponent::send_device_info_() { void MQTTClientComponent::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) tag; if (level <= this->log_level_ && this->is_connected()) { - this->publish({.topic = this->log_message_.topic, - .payload = std::string(message, message_len), - .qos = this->log_message_.qos, - .retain = this->log_message_.retain}); + this->publish(this->log_message_.topic.c_str(), message, message_len, this->log_message_.qos, + this->log_message_.retain); } } #endif From 86feb4e27a3f4943197b19485fb7f83c64300269 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 13:54:37 -0600 Subject: [PATCH 5/9] [rtttl] Convert state_to_string to PROGMEM_STRING_TABLE (#13807) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/rtttl/rtttl.cpp | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index c179282c50..5a64f37da4 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -2,6 +2,7 @@ #include #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" namespace esphome { namespace rtttl { @@ -375,22 +376,13 @@ void Rtttl::loop() { } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE +// RTTTL state strings indexed by State enum (0-4): STOPPED, INIT, STARTING, RUNNING, STOPPING, plus UNKNOWN fallback +PROGMEM_STRING_TABLE(RtttlStateStrings, "STATE_STOPPED", "STATE_INIT", "STATE_STARTING", "STATE_RUNNING", + "STATE_STOPPING", "UNKNOWN"); + static const LogString *state_to_string(State state) { - switch (state) { - case STATE_STOPPED: - return LOG_STR("STATE_STOPPED"); - case STATE_STARTING: - return LOG_STR("STATE_STARTING"); - case STATE_RUNNING: - return LOG_STR("STATE_RUNNING"); - case STATE_STOPPING: - return LOG_STR("STATE_STOPPING"); - case STATE_INIT: - return LOG_STR("STATE_INIT"); - default: - return LOG_STR("UNKNOWN"); - } -}; + return RtttlStateStrings::get_log_str(static_cast(state), RtttlStateStrings::LAST_INDEX); +} #endif void Rtttl::set_state_(State state) { From 5365faa8773c889de4e31c7eac16a6db23b760ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 13:54:48 -0600 Subject: [PATCH 6/9] [debug] Move ESP8266 switch tables to flash with PROGMEM_STRING_TABLE (#13813) --- esphome/components/debug/debug_esp8266.cpp | 68 +++++++++++----------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/esphome/components/debug/debug_esp8266.cpp b/esphome/components/debug/debug_esp8266.cpp index a4b6468b49..1a07ec4f3a 100644 --- a/esphome/components/debug/debug_esp8266.cpp +++ b/esphome/components/debug/debug_esp8266.cpp @@ -1,6 +1,7 @@ #include "debug_component.h" #ifdef USE_ESP8266 #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include extern "C" { @@ -19,27 +20,38 @@ namespace debug { static const char *const TAG = "debug"; +// PROGMEM string table for reset reasons, indexed by reason code (0-6), with "Unknown" as fallback +// clang-format off +PROGMEM_STRING_TABLE(ResetReasonStrings, + "Power On", // 0 = REASON_DEFAULT_RST + "Hardware Watchdog", // 1 = REASON_WDT_RST + "Exception", // 2 = REASON_EXCEPTION_RST + "Software Watchdog", // 3 = REASON_SOFT_WDT_RST + "Software/System restart", // 4 = REASON_SOFT_RESTART + "Deep-Sleep Wake", // 5 = REASON_DEEP_SLEEP_AWAKE + "External System", // 6 = REASON_EXT_SYS_RST + "Unknown" // 7 = fallback +); +// clang-format on +static_assert(REASON_DEFAULT_RST == 0, "Reset reason enum values must match table indices"); +static_assert(REASON_WDT_RST == 1, "Reset reason enum values must match table indices"); +static_assert(REASON_EXCEPTION_RST == 2, "Reset reason enum values must match table indices"); +static_assert(REASON_SOFT_WDT_RST == 3, "Reset reason enum values must match table indices"); +static_assert(REASON_SOFT_RESTART == 4, "Reset reason enum values must match table indices"); +static_assert(REASON_DEEP_SLEEP_AWAKE == 5, "Reset reason enum values must match table indices"); +static_assert(REASON_EXT_SYS_RST == 6, "Reset reason enum values must match table indices"); + +// PROGMEM string table for flash chip modes, indexed by mode code (0-3), with "UNKNOWN" as fallback +PROGMEM_STRING_TABLE(FlashModeStrings, "QIO", "QOUT", "DIO", "DOUT", "UNKNOWN"); +static_assert(FM_QIO == 0, "Flash mode enum values must match table indices"); +static_assert(FM_QOUT == 1, "Flash mode enum values must match table indices"); +static_assert(FM_DIO == 2, "Flash mode enum values must match table indices"); +static_assert(FM_DOUT == 3, "Flash mode enum values must match table indices"); + // Get reset reason string from reason code (no heap allocation) // Returns LogString* pointing to flash (PROGMEM) on ESP8266 static const LogString *get_reset_reason_str(uint32_t reason) { - switch (reason) { - case REASON_DEFAULT_RST: - return LOG_STR("Power On"); - case REASON_WDT_RST: - return LOG_STR("Hardware Watchdog"); - case REASON_EXCEPTION_RST: - return LOG_STR("Exception"); - case REASON_SOFT_WDT_RST: - return LOG_STR("Software Watchdog"); - case REASON_SOFT_RESTART: - return LOG_STR("Software/System restart"); - case REASON_DEEP_SLEEP_AWAKE: - return LOG_STR("Deep-Sleep Wake"); - case REASON_EXT_SYS_RST: - return LOG_STR("External System"); - default: - return LOG_STR("Unknown"); - } + return ResetReasonStrings::get_log_str(static_cast(reason), ResetReasonStrings::LAST_INDEX); } // Size for core version hex buffer @@ -92,23 +104,9 @@ size_t DebugComponent::get_device_info_(std::span constexpr size_t size = DEVICE_INFO_BUFFER_SIZE; char *buf = buffer.data(); - const LogString *flash_mode; - switch (ESP.getFlashChipMode()) { // NOLINT(readability-static-accessed-through-instance) - case FM_QIO: - flash_mode = LOG_STR("QIO"); - break; - case FM_QOUT: - flash_mode = LOG_STR("QOUT"); - break; - case FM_DIO: - flash_mode = LOG_STR("DIO"); - break; - case FM_DOUT: - flash_mode = LOG_STR("DOUT"); - break; - default: - flash_mode = LOG_STR("UNKNOWN"); - } + const LogString *flash_mode = FlashModeStrings::get_log_str( + static_cast(ESP.getFlashChipMode()), // NOLINT(readability-static-accessed-through-instance) + FlashModeStrings::LAST_INDEX); uint32_t flash_size = ESP.getFlashChipSize() / 1024; // NOLINT(readability-static-accessed-through-instance) uint32_t flash_speed = ESP.getFlashChipSpeed() / 1000000; // NOLINT(readability-static-accessed-through-instance) ESP_LOGD(TAG, "Flash Chip: Size=%" PRIu32 "kB Speed=%" PRIu32 "MHz Mode=%s", flash_size, flash_speed, From e2fad9a6c911b6c60663db91a9cf30d8ab4ee991 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 13:55:01 -0600 Subject: [PATCH 7/9] [sprinkler] Convert state and request origin strings to PROGMEM_STRING_TABLE (#13806) --- esphome/components/sprinkler/sprinkler.cpp | 42 ++++++---------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index eae6ecbf31..9e423c1760 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -4,6 +4,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include #include @@ -1544,42 +1545,19 @@ void Sprinkler::log_multiplier_zero_warning_(const LogString *method_name) { ESP_LOGW(TAG, "%s called but multiplier is set to zero; no action taken", LOG_STR_ARG(method_name)); } +// Request origin strings indexed by SprinklerValveRunRequestOrigin enum (0-2): USER, CYCLE, QUEUE +PROGMEM_STRING_TABLE(SprinklerRequestOriginStrings, "USER", "CYCLE", "QUEUE", "UNKNOWN"); + const LogString *Sprinkler::req_as_str_(SprinklerValveRunRequestOrigin origin) { - switch (origin) { - case USER: - return LOG_STR("USER"); - - case CYCLE: - return LOG_STR("CYCLE"); - - case QUEUE: - return LOG_STR("QUEUE"); - - default: - return LOG_STR("UNKNOWN"); - } + return SprinklerRequestOriginStrings::get_log_str(static_cast(origin), + SprinklerRequestOriginStrings::LAST_INDEX); } +// Sprinkler state strings indexed by SprinklerState enum (0-4): IDLE, STARTING, ACTIVE, STOPPING, BYPASS +PROGMEM_STRING_TABLE(SprinklerStateStrings, "IDLE", "STARTING", "ACTIVE", "STOPPING", "BYPASS", "UNKNOWN"); + const LogString *Sprinkler::state_as_str_(SprinklerState state) { - switch (state) { - case IDLE: - return LOG_STR("IDLE"); - - case STARTING: - return LOG_STR("STARTING"); - - case ACTIVE: - return LOG_STR("ACTIVE"); - - case STOPPING: - return LOG_STR("STOPPING"); - - case BYPASS: - return LOG_STR("BYPASS"); - - default: - return LOG_STR("UNKNOWN"); - } + return SprinklerStateStrings::get_log_str(static_cast(state), SprinklerStateStrings::LAST_INDEX); } void Sprinkler::start_timer_(const SprinklerTimerIndex timer_index) { From c65d3a0072d9d7e2202b6d657c41d035b2a9fa90 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 13:55:16 -0600 Subject: [PATCH 8/9] [mqtt] Add zero-allocation topic getters to MQTT_COMPONENT_CUSTOM_TOPIC macro (#13811) --- esphome/components/mqtt/mqtt_climate.cpp | 34 +++++++++++++----------- esphome/components/mqtt/mqtt_component.h | 5 ++++ esphome/components/mqtt/mqtt_cover.cpp | 6 ++--- esphome/components/mqtt/mqtt_fan.cpp | 9 ++++--- esphome/components/mqtt/mqtt_valve.cpp | 4 +-- 5 files changed, 34 insertions(+), 24 deletions(-) diff --git a/esphome/components/mqtt/mqtt_climate.cpp b/esphome/components/mqtt/mqtt_climate.cpp index 158cfb5552..81b2e0e8db 100644 --- a/esphome/components/mqtt/mqtt_climate.cpp +++ b/esphome/components/mqtt/mqtt_climate.cpp @@ -300,9 +300,11 @@ const EntityBase *MQTTClimateComponent::get_entity() const { return this->device bool MQTTClimateComponent::publish_state_() { auto traits = this->device_->get_traits(); + // Reusable stack buffer for topic construction (avoids heap allocation per publish) + char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; // mode bool success = true; - if (!this->publish(this->get_mode_state_topic(), climate_mode_to_mqtt_str(this->device_->mode))) + if (!this->publish(this->get_mode_state_topic_to(topic_buf), climate_mode_to_mqtt_str(this->device_->mode))) success = false; int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); @@ -311,68 +313,70 @@ bool MQTTClimateComponent::publish_state_() { if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE) && !std::isnan(this->device_->current_temperature)) { len = value_accuracy_to_buf(payload, this->device_->current_temperature, current_accuracy); - if (!this->publish(this->get_current_temperature_state_topic(), payload, len)) + if (!this->publish(this->get_current_temperature_state_topic_to(topic_buf), payload, len)) success = false; } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { len = value_accuracy_to_buf(payload, this->device_->target_temperature_low, target_accuracy); - if (!this->publish(this->get_target_temperature_low_state_topic(), payload, len)) + if (!this->publish(this->get_target_temperature_low_state_topic_to(topic_buf), payload, len)) success = false; len = value_accuracy_to_buf(payload, this->device_->target_temperature_high, target_accuracy); - if (!this->publish(this->get_target_temperature_high_state_topic(), payload, len)) + if (!this->publish(this->get_target_temperature_high_state_topic_to(topic_buf), payload, len)) success = false; } else { len = value_accuracy_to_buf(payload, this->device_->target_temperature, target_accuracy); - if (!this->publish(this->get_target_temperature_state_topic(), payload, len)) + if (!this->publish(this->get_target_temperature_state_topic_to(topic_buf), payload, len)) success = false; } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY) && !std::isnan(this->device_->current_humidity)) { len = value_accuracy_to_buf(payload, this->device_->current_humidity, 0); - if (!this->publish(this->get_current_humidity_state_topic(), payload, len)) + if (!this->publish(this->get_current_humidity_state_topic_to(topic_buf), payload, len)) success = false; } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY) && !std::isnan(this->device_->target_humidity)) { len = value_accuracy_to_buf(payload, this->device_->target_humidity, 0); - if (!this->publish(this->get_target_humidity_state_topic(), payload, len)) + if (!this->publish(this->get_target_humidity_state_topic_to(topic_buf), payload, len)) success = false; } if (traits.get_supports_presets() || !traits.get_supported_custom_presets().empty()) { if (this->device_->has_custom_preset()) { - if (!this->publish(this->get_preset_state_topic(), this->device_->get_custom_preset())) + if (!this->publish(this->get_preset_state_topic_to(topic_buf), this->device_->get_custom_preset().c_str())) success = false; } else if (this->device_->preset.has_value()) { - if (!this->publish(this->get_preset_state_topic(), climate_preset_to_mqtt_str(this->device_->preset.value()))) + if (!this->publish(this->get_preset_state_topic_to(topic_buf), + climate_preset_to_mqtt_str(this->device_->preset.value()))) success = false; - } else if (!this->publish(this->get_preset_state_topic(), "")) { + } else if (!this->publish(this->get_preset_state_topic_to(topic_buf), "")) { success = false; } } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { - if (!this->publish(this->get_action_state_topic(), climate_action_to_mqtt_str(this->device_->action))) + if (!this->publish(this->get_action_state_topic_to(topic_buf), climate_action_to_mqtt_str(this->device_->action))) success = false; } if (traits.get_supports_fan_modes()) { if (this->device_->has_custom_fan_mode()) { - if (!this->publish(this->get_fan_mode_state_topic(), this->device_->get_custom_fan_mode())) + if (!this->publish(this->get_fan_mode_state_topic_to(topic_buf), this->device_->get_custom_fan_mode().c_str())) success = false; } else if (this->device_->fan_mode.has_value()) { - if (!this->publish(this->get_fan_mode_state_topic(), + if (!this->publish(this->get_fan_mode_state_topic_to(topic_buf), climate_fan_mode_to_mqtt_str(this->device_->fan_mode.value()))) success = false; - } else if (!this->publish(this->get_fan_mode_state_topic(), "")) { + } else if (!this->publish(this->get_fan_mode_state_topic_to(topic_buf), "")) { success = false; } } if (traits.get_supports_swing_modes()) { - if (!this->publish(this->get_swing_mode_state_topic(), climate_swing_mode_to_mqtt_str(this->device_->swing_mode))) + if (!this->publish(this->get_swing_mode_state_topic_to(topic_buf), + climate_swing_mode_to_mqtt_str(this->device_->swing_mode))) success = false; } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index eb98158647..853712940a 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -59,6 +59,11 @@ void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, b \ public: \ void set_custom_##name##_##type##_topic(const std::string &topic) { this->custom_##name##_##type##_topic_ = topic; } \ + StringRef get_##name##_##type##_topic_to(std::span buf) const { \ + if (!this->custom_##name##_##type##_topic_.empty()) \ + return StringRef(this->custom_##name##_##type##_topic_.data(), this->custom_##name##_##type##_topic_.size()); \ + return this->get_default_topic_for_to_(buf, #name "/" #type, sizeof(#name "/" #type) - 1); \ + } \ std::string get_##name##_##type##_topic() const { \ if (this->custom_##name##_##type##_topic_.empty()) \ return this->get_default_topic_for_(#name "/" #type); \ diff --git a/esphome/components/mqtt/mqtt_cover.cpp b/esphome/components/mqtt/mqtt_cover.cpp index 50e68ecbcc..c21af413ed 100644 --- a/esphome/components/mqtt/mqtt_cover.cpp +++ b/esphome/components/mqtt/mqtt_cover.cpp @@ -112,19 +112,19 @@ bool MQTTCoverComponent::send_initial_state() { return this->publish_state(); } bool MQTTCoverComponent::publish_state() { auto traits = this->cover_->get_traits(); bool success = true; + char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; if (traits.get_supports_position()) { char pos[VALUE_ACCURACY_MAX_LEN]; size_t len = value_accuracy_to_buf(pos, roundf(this->cover_->position * 100), 0); - if (!this->publish(this->get_position_state_topic(), pos, len)) + if (!this->publish(this->get_position_state_topic_to(topic_buf), pos, len)) success = false; } if (traits.get_supports_tilt()) { char pos[VALUE_ACCURACY_MAX_LEN]; size_t len = value_accuracy_to_buf(pos, roundf(this->cover_->tilt * 100), 0); - if (!this->publish(this->get_tilt_state_topic(), pos, len)) + if (!this->publish(this->get_tilt_state_topic_to(topic_buf), pos, len)) success = false; } - char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; if (!this->publish(this->get_state_topic_to_(topic_buf), cover_state_to_mqtt_str(this->cover_->current_operation, this->cover_->position, traits.get_supports_position()))) diff --git a/esphome/components/mqtt/mqtt_fan.cpp b/esphome/components/mqtt/mqtt_fan.cpp index 84d51895c5..ae2b8c4600 100644 --- a/esphome/components/mqtt/mqtt_fan.cpp +++ b/esphome/components/mqtt/mqtt_fan.cpp @@ -173,19 +173,20 @@ bool MQTTFanComponent::publish_state() { this->publish(this->get_state_topic_to_(topic_buf), state_s); bool failed = false; if (this->state_->get_traits().supports_direction()) { - bool success = this->publish(this->get_direction_state_topic(), fan_direction_to_mqtt_str(this->state_->direction)); + bool success = this->publish(this->get_direction_state_topic_to(topic_buf), + fan_direction_to_mqtt_str(this->state_->direction)); failed = failed || !success; } if (this->state_->get_traits().supports_oscillation()) { - bool success = - this->publish(this->get_oscillation_state_topic(), fan_oscillation_to_mqtt_str(this->state_->oscillating)); + bool success = this->publish(this->get_oscillation_state_topic_to(topic_buf), + fan_oscillation_to_mqtt_str(this->state_->oscillating)); failed = failed || !success; } auto traits = this->state_->get_traits(); if (traits.supports_speed()) { char buf[12]; size_t len = buf_append_printf(buf, sizeof(buf), 0, "%d", this->state_->speed); - bool success = this->publish(this->get_speed_level_state_topic(), buf, len); + bool success = this->publish(this->get_speed_level_state_topic_to(topic_buf), buf, len); failed = failed || !success; } return !failed; diff --git a/esphome/components/mqtt/mqtt_valve.cpp b/esphome/components/mqtt/mqtt_valve.cpp index 16e25f6a8a..2b9f02858b 100644 --- a/esphome/components/mqtt/mqtt_valve.cpp +++ b/esphome/components/mqtt/mqtt_valve.cpp @@ -87,13 +87,13 @@ bool MQTTValveComponent::send_initial_state() { return this->publish_state(); } bool MQTTValveComponent::publish_state() { auto traits = this->valve_->get_traits(); bool success = true; + char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; if (traits.get_supports_position()) { char pos[VALUE_ACCURACY_MAX_LEN]; size_t len = value_accuracy_to_buf(pos, roundf(this->valve_->position * 100), 0); - if (!this->publish(this->get_position_state_topic(), pos, len)) + if (!this->publish(this->get_position_state_topic_to(topic_buf), pos, len)) success = false; } - char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; if (!this->publish(this->get_state_topic_to_(topic_buf), valve_state_to_mqtt_str(this->valve_->current_operation, this->valve_->position, traits.get_supports_position()))) From 868a2151e3ef63606e933257e49200216fe50eb2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 13:56:12 -0600 Subject: [PATCH 9/9] [web_server_idf] Reduce heap allocations by using stack buffers (#13549) --- .../web_server_idf/web_server_idf.cpp | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index ac7f99bef7..0dd1948dcc 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -344,14 +344,15 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw memcpy(user_info + user_len + 1, password, pass_len); user_info[user_info_len] = '\0'; - size_t n = 0, out; - esp_crypto_base64_encode(nullptr, 0, &n, reinterpret_cast(user_info), user_info_len); - - auto digest = std::unique_ptr(new char[n + 1]); - esp_crypto_base64_encode(reinterpret_cast(digest.get()), n, &out, + // Base64 output size is ceil(input_len * 4/3) + 1, with input bounded to 256 bytes + // max output is ceil(256 * 4/3) + 1 = 343 bytes, use 350 for safety + constexpr size_t max_digest_len = 350; + char digest[max_digest_len]; + size_t out; + esp_crypto_base64_encode(reinterpret_cast(digest), max_digest_len, &out, reinterpret_cast(user_info), user_info_len); - return strcmp(digest.get(), auth_str + auth_prefix_len) == 0; + return strcmp(digest, auth_str + auth_prefix_len) == 0; } void AsyncWebServerRequest::requestAuthentication(const char *realm) const { @@ -861,12 +862,12 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c } }); - // Process data - std::unique_ptr buffer(new char[MULTIPART_CHUNK_SIZE]); + // Process data - use stack buffer to avoid heap allocation + char buffer[MULTIPART_CHUNK_SIZE]; size_t bytes_since_yield = 0; for (size_t remaining = r->content_len; remaining > 0;) { - int recv_len = httpd_req_recv(r, buffer.get(), std::min(remaining, MULTIPART_CHUNK_SIZE)); + int recv_len = httpd_req_recv(r, buffer, std::min(remaining, MULTIPART_CHUNK_SIZE)); if (recv_len <= 0) { httpd_resp_send_err(r, recv_len == HTTPD_SOCK_ERR_TIMEOUT ? HTTPD_408_REQ_TIMEOUT : HTTPD_400_BAD_REQUEST, @@ -874,7 +875,7 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c return recv_len == HTTPD_SOCK_ERR_TIMEOUT ? ESP_ERR_TIMEOUT : ESP_FAIL; } - if (reader->parse(buffer.get(), recv_len) != static_cast(recv_len)) { + if (reader->parse(buffer, recv_len) != static_cast(recv_len)) { ESP_LOGW(TAG, "Multipart parser error"); httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); return ESP_FAIL;