From 3f892711c7c31e92ba4a8a732e8e34a4338bf54e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 11:09:42 -1000 Subject: [PATCH 01/51] [core][opentherm] Add format_bin_to(), soft-deprecate format_bin() (#13232) --- esphome/components/opentherm/opentherm.cpp | 5 +- esphome/core/helpers.cpp | 26 ++++++++-- esphome/core/helpers.h | 57 ++++++++++++++++++++++ script/ci-custom.py | 2 + 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index c6443f12821b..2bf438a52fb5 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -561,8 +561,9 @@ const char *OpenTherm::message_id_to_str(MessageId id) { } void OpenTherm::debug_data(OpenthermData &data) { - ESP_LOGD(TAG, "%s %s %s %s", format_bin(data.type).c_str(), format_bin(data.id).c_str(), - format_bin(data.valueHB).c_str(), format_bin(data.valueLB).c_str()); + char type_buf[9], id_buf[9], hb_buf[9], lb_buf[9]; + ESP_LOGD(TAG, "%s %s %s %s", format_bin_to(type_buf, data.type), format_bin_to(id_buf, data.id), + format_bin_to(hb_buf, data.valueHB), format_bin_to(lb_buf, data.valueLB)); ESP_LOGD(TAG, "type: %s; id: %u; HB: %u; LB: %u; uint_16: %u; float: %f", this->message_type_to_str((MessageType) data.type), data.id, data.valueHB, data.valueLB, data.u16(), data.f88()); diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 5de1c7056227..4e3761675da8 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -404,15 +404,31 @@ std::string format_hex_pretty(const std::string &data, char separator, bool show return format_hex_pretty_uint8(reinterpret_cast(data.data()), data.length(), separator, show_length); } -std::string format_bin(const uint8_t *data, size_t length) { - std::string result; - result.resize(length * 8); - for (size_t byte_idx = 0; byte_idx < length; byte_idx++) { +char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length) { + if (buffer_size == 0) { + return buffer; + } + // Calculate max bytes we can format: each byte needs 8 chars + size_t max_bytes = (buffer_size - 1) / 8; + if (max_bytes == 0 || length == 0) { + buffer[0] = '\0'; + return buffer; + } + size_t bytes_to_format = std::min(length, max_bytes); + + for (size_t byte_idx = 0; byte_idx < bytes_to_format; byte_idx++) { for (size_t bit_idx = 0; bit_idx < 8; bit_idx++) { - result[byte_idx * 8 + bit_idx] = ((data[byte_idx] >> (7 - bit_idx)) & 1) + '0'; + buffer[byte_idx * 8 + bit_idx] = ((data[byte_idx] >> (7 - bit_idx)) & 1) + '0'; } } + buffer[bytes_to_format * 8] = '\0'; + return buffer; +} +std::string format_bin(const uint8_t *data, size_t length) { + std::string result; + result.resize(length * 8); + format_bin_to(&result[0], length * 8 + 1, data, length); return result; } diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 0acc6bdc6038..409c691cb103 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1096,9 +1096,66 @@ std::string format_hex_pretty(T val, char separator = '.', bool show_length = tr return format_hex_pretty(reinterpret_cast(&val), sizeof(T), separator, show_length); } +/// Calculate buffer size needed for format_bin_to: "01234567...\0" = bytes * 8 + 1 +constexpr size_t format_bin_size(size_t byte_count) { return byte_count * 8 + 1; } + +/** Format byte array as binary string to buffer. + * + * Each byte is formatted as 8 binary digits (MSB first). + * Truncates output if data exceeds buffer capacity. + * + * @param buffer Output buffer to write to. + * @param buffer_size Size of the output buffer. + * @param data Pointer to the byte array to format. + * @param length Number of bytes in the array. + * @return Pointer to buffer. + * + * Buffer size needed: length * 8 + 1 (use format_bin_size()). + * + * Example: + * @code + * char buf[9]; // format_bin_size(1) + * format_bin_to(buf, sizeof(buf), data, 1); // "10101011" + * @endcode + */ +char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length); + +/// Format byte array as binary to buffer. Automatically deduces buffer size. +template inline char *format_bin_to(char (&buffer)[N], const uint8_t *data, size_t length) { + static_assert(N >= 9, "Buffer must hold at least one binary byte (9 chars)"); + return format_bin_to(buffer, N, data, length); +} + +/** Format an unsigned integer in binary to buffer, MSB first. + * + * @tparam N Buffer size (must be >= sizeof(T) * 8 + 1). + * @tparam T Unsigned integer type. + * @param buffer Output buffer to write to. + * @param val The unsigned integer value to format. + * @return Pointer to buffer. + * + * Example: + * @code + * char buf[9]; // format_bin_size(sizeof(uint8_t)) + * format_bin_to(buf, uint8_t{0xAA}); // "10101010" + * char buf16[17]; // format_bin_size(sizeof(uint16_t)) + * format_bin_to(buf16, uint16_t{0x1234}); // "0001001000110100" + * @endcode + */ +template::value, int> = 0> +inline char *format_bin_to(char (&buffer)[N], T val) { + static_assert(N >= sizeof(T) * 8 + 1, "Buffer too small for type"); + val = convert_big_endian(val); + return format_bin_to(buffer, reinterpret_cast(&val), sizeof(T)); +} + /// Format the byte array \p data of length \p len in binary. +/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. +/// Causes heap fragmentation on long-running devices. std::string format_bin(const uint8_t *data, size_t length); /// Format an unsigned integer in binary, starting with the most significant byte. +/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. +/// Causes heap fragmentation on long-running devices. template::value, int> = 0> std::string format_bin(T val) { val = convert_big_endian(val); return format_bin(reinterpret_cast(&val), sizeof(T)); diff --git a/script/ci-custom.py b/script/ci-custom.py index e63e61e09605..e227ec873e9b 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -682,6 +682,7 @@ def lint_trailing_whitespace(fname, match): # Heap-allocating helpers that cause fragmentation on long-running embedded devices. # These return std::string and should be replaced with stack-based alternatives. HEAP_ALLOCATING_HELPERS = { + "format_bin": "format_bin_to() with a stack buffer", "format_hex": "format_hex_to() with a stack buffer", "format_hex_pretty": "format_hex_pretty_to() with a stack buffer", "format_mac_address_pretty": "format_mac_addr_upper() with a stack buffer", @@ -699,6 +700,7 @@ def lint_trailing_whitespace(fname, match): # get_mac_address(?!_) ensures we don't match get_mac_address_into_buffer, etc. # CPP_RE_EOL captures rest of line so NOLINT comments are detected r"[^\w](" + r"format_bin(?!_)|" r"format_hex(?!_)|" r"format_hex_pretty(?!_)|" r"format_mac_address_pretty|" From b25a2f8d8e0d31284a3489431b310d02be1ef6ab Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 17 Jan 2026 16:01:13 -0600 Subject: [PATCH 02/51] [infrared][web_server] Implement initial `web_server` support (#13202) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/web_server/list_entities.cpp | 2 +- esphome/components/web_server/web_server.cpp | 116 ++++++++++++++++++ esphome/components/web_server/web_server.h | 10 ++ 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index 0af95213261c..845829806209 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -143,7 +143,7 @@ bool ListEntitiesIterator::on_water_heater(water_heater::WaterHeater *obj) { #ifdef USE_INFRARED bool ListEntitiesIterator::on_infrared(infrared::Infrared *obj) { - // Infrared web_server support not yet implemented - this stub acknowledges the entity + this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::infrared_all_json_generator); return true; } #endif diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 0e71d822333e..593e0c32e366 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -33,6 +33,10 @@ #include "esphome/components/water_heater/water_heater.h" #endif +#ifdef USE_INFRARED +#include "esphome/components/infrared/infrared.h" +#endif + #ifdef USE_WEBSERVER_LOCAL #if USE_WEBSERVER_VERSION == 2 #include "server_index_v2.h" @@ -1952,6 +1956,110 @@ std::string WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDe } #endif +#ifdef USE_INFRARED +void WebServer::handle_infrared_request(AsyncWebServerRequest *request, const UrlMatch &match) { + for (infrared::Infrared *obj : App.get_infrareds()) { + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) + continue; + + if (request->method() == HTTP_GET && entity_match.action_is_empty) { + auto detail = get_request_detail(request); + std::string data = this->infrared_json_(obj, detail); + request->send(200, ESPHOME_F("application/json"), data.c_str()); + return; + } + if (!match.method_equals(ESPHOME_F("transmit"))) { + request->send(404); + return; + } + + // Only allow transmit if the device supports it + if (!obj->has_transmitter()) { + request->send(400, ESPHOME_F("text/plain"), "Device does not support transmission"); + return; + } + + // Parse parameters + auto call = obj->make_call(); + + // Parse carrier frequency (optional) + if (request->hasParam(ESPHOME_F("carrier_frequency"))) { + auto value = parse_number(request->getParam(ESPHOME_F("carrier_frequency"))->value().c_str()); + if (value.has_value()) { + call.set_carrier_frequency(*value); + } + } + + // Parse repeat count (optional, defaults to 1) + if (request->hasParam(ESPHOME_F("repeat_count"))) { + auto value = parse_number(request->getParam(ESPHOME_F("repeat_count"))->value().c_str()); + if (value.has_value()) { + call.set_repeat_count(*value); + } + } + + // Parse base64url-encoded raw timings (required) + // Base64url is URL-safe: uses A-Za-z0-9-_ (no special characters needing escaping) + if (!request->hasParam(ESPHOME_F("data"))) { + request->send(400, ESPHOME_F("text/plain"), "Missing 'data' parameter"); + return; + } + + // .c_str() is required for Arduino framework where value() returns Arduino String instead of std::string + std::string encoded = + request->getParam(ESPHOME_F("data"))->value().c_str(); // NOLINT(readability-redundant-string-cstr) + + // Validate base64url is not empty + if (encoded.empty()) { + request->send(400, ESPHOME_F("text/plain"), "Empty 'data' parameter"); + return; + } + +#ifdef USE_ESP8266 + // ESP8266 is single-threaded, call directly + call.set_raw_timings_base64url(encoded); + call.perform(); +#else + // Defer to main loop for thread safety. Move encoded string into lambda to ensure + // it outlives the call - set_raw_timings_base64url stores a pointer, so the string + // must remain valid until perform() completes. + this->defer([call, encoded = std::move(encoded)]() mutable { + call.set_raw_timings_base64url(encoded); + call.perform(); + }); +#endif + + request->send(200); + return; + } + request->send(404); +} + +std::string WebServer::infrared_all_json_generator(WebServer *web_server, void *source) { + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson + return web_server->infrared_json_(static_cast(source), DETAIL_ALL); +} + +std::string WebServer::infrared_json_(infrared::Infrared *obj, JsonDetail start_config) { + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "infrared", "", 0, start_config); + + auto traits = obj->get_traits(); + + root[ESPHOME_F("supports_transmitter")] = traits.get_supports_transmitter(); + root[ESPHOME_F("supports_receiver")] = traits.get_supports_receiver(); + + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); +} +#endif + #ifdef USE_EVENT void WebServer::on_event(event::Event *obj) { if (!this->include_internal_ && obj->is_internal()) @@ -2187,6 +2295,9 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { #endif #ifdef USE_WATER_HEATER "water_heater", +#endif +#ifdef USE_INFRARED + "infrared", #endif }; @@ -2352,6 +2463,11 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { else if (match.domain_equals(ESPHOME_F("water_heater"))) { this->handle_water_heater_request(request, match); } +#endif +#ifdef USE_INFRARED + else if (match.domain_equals(ESPHOME_F("infrared"))) { + this->handle_infrared_request(request, match); + } #endif else { // No matching handler found - send 404 diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index c434d664cf4e..92a5c7edee03 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -460,6 +460,13 @@ class WebServer : public Controller, static std::string water_heater_all_json_generator(WebServer *web_server, void *source); #endif +#ifdef USE_INFRARED + /// Handle an infrared request under '/infrared//transmit'. + void handle_infrared_request(AsyncWebServerRequest *request, const UrlMatch &match); + + static std::string infrared_all_json_generator(WebServer *web_server, void *source); +#endif + #ifdef USE_EVENT void on_event(event::Event *obj) override; @@ -662,6 +669,9 @@ class WebServer : public Controller, #ifdef USE_WATER_HEATER std::string water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config); #endif +#ifdef USE_INFRARED + std::string infrared_json_(infrared::Infrared *obj, JsonDetail start_config); +#endif #ifdef USE_UPDATE std::string update_json_(update::UpdateEntity *obj, JsonDetail start_config); #endif From d31b733dce983f66171c1ab0d0b5d01ab2ea1f40 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 12:06:25 -1000 Subject: [PATCH 03/51] [light] Store color mode JSON strings in flash on ESP8266 (#13314) --- .../components/light/light_json_schema.cpp | 52 ++++++++++--------- esphome/core/progmem.h | 4 ++ 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index f37098073730..631f59221f7b 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -1,4 +1,5 @@ #include "light_json_schema.h" +#include "color_mode.h" #include "light_output.h" #include "esphome/core/progmem.h" @@ -8,29 +9,32 @@ namespace esphome::light { // See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema -// Get JSON string for color mode using linear search (avoids large switch jump table) -static const char *get_color_mode_json_str(ColorMode mode) { - // Parallel arrays: mode values and their corresponding strings - // Uses less RAM than a switch jump table on sparse enum values - static constexpr ColorMode MODES[] = { - ColorMode::ON_OFF, - ColorMode::BRIGHTNESS, - ColorMode::WHITE, - ColorMode::COLOR_TEMPERATURE, - ColorMode::COLD_WARM_WHITE, - ColorMode::RGB, - ColorMode::RGB_WHITE, - ColorMode::RGB_COLOR_TEMPERATURE, - ColorMode::RGB_COLD_WARM_WHITE, - }; - static constexpr const char *STRINGS[] = { - "onoff", "brightness", "white", "color_temp", "cwww", "rgb", "rgbw", "rgbct", "rgbww", - }; - for (size_t i = 0; i < sizeof(MODES) / sizeof(MODES[0]); i++) { - if (MODES[i] == mode) - return STRINGS[i]; - } - return nullptr; +// Get JSON string for color mode. +// ColorMode enum values are sparse bitmasks (0, 1, 3, 7, 11, 19, 35, 39, 47, 51) which would +// generate a large jump table. Converting to bit index (0-9) allows a compact switch. +static ProgmemStr get_color_mode_json_str(ColorMode mode) { + switch (ColorModeBitPolicy::to_bit(mode)) { + case 1: + return ESPHOME_F("onoff"); + case 2: + return ESPHOME_F("brightness"); + case 3: + return ESPHOME_F("white"); + case 4: + return ESPHOME_F("color_temp"); + case 5: + return ESPHOME_F("cwww"); + case 6: + return ESPHOME_F("rgb"); + case 7: + return ESPHOME_F("rgbw"); + case 8: + return ESPHOME_F("rgbct"); + case 9: + return ESPHOME_F("rgbww"); + default: + return nullptr; + } } void LightJSONSchema::dump_json(LightState &state, JsonObject root) { @@ -44,7 +48,7 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) { auto values = state.remote_values; const auto color_mode = values.get_color_mode(); - const char *mode_str = get_color_mode_json_str(color_mode); + const auto *mode_str = get_color_mode_json_str(color_mode); if (mode_str != nullptr) { root[ESPHOME_F("color_mode")] = mode_str; } diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index fe9c9b5a7514..6c3e4cec96eb 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -12,6 +12,8 @@ #define ESPHOME_strncpy_P strncpy_P #define ESPHOME_strncat_P strncat_P #define ESPHOME_snprintf_P snprintf_P +// Type for pointers to PROGMEM strings (for use with ESPHOME_F return values) +using ProgmemStr = const __FlashStringHelper *; #else #define ESPHOME_F(string_literal) (string_literal) #define ESPHOME_PGM_P const char * @@ -19,4 +21,6 @@ #define ESPHOME_strncpy_P strncpy #define ESPHOME_strncat_P strncat #define ESPHOME_snprintf_P snprintf +// Type for pointers to strings (no PROGMEM on non-ESP8266 platforms) +using ProgmemStr = const char *; #endif From e4fb6988ff96ba121f646bf7fc842c2e7fc0ea6d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 12:29:29 -1000 Subject: [PATCH 04/51] [web_server] Use ESPHOME_F for canHandle domain checks to reduce ESP8266 RAM (#13315) Co-authored-by: Keith Burzinski --- esphome/components/web_server/web_server.cpp | 112 ++++++++++--------- 1 file changed, 58 insertions(+), 54 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 593e0c32e366..61b9ea516337 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -2191,24 +2191,21 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { const auto &url = request->url(); const auto method = request->method(); - // Static URL checks - static const char *const STATIC_URLS[] = { - "/", + // Static URL checks - use ESPHOME_F to keep strings in flash on ESP8266 + if (url == ESPHOME_F("/")) + return true; #if !defined(USE_ESP32) && defined(USE_ARDUINO) - "/events", + if (url == ESPHOME_F("/events")) + return true; #endif #ifdef USE_WEBSERVER_CSS_INCLUDE - "/0.css", + if (url == ESPHOME_F("/0.css")) + return true; #endif #ifdef USE_WEBSERVER_JS_INCLUDE - "/0.js", + if (url == ESPHOME_F("/0.js")) + return true; #endif - }; - - for (const auto &static_url : STATIC_URLS) { - if (url == static_url) - return true; - } #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS if (method == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) @@ -2228,93 +2225,100 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { if (!is_get_or_post) return false; - // Use lookup tables for domain checks - static const char *const GET_ONLY_DOMAINS[] = { + // Check GET-only domains - use ESPHOME_F to keep strings in flash on ESP8266 + if (is_get) { #ifdef USE_SENSOR - "sensor", + if (match.domain_equals(ESPHOME_F("sensor"))) + return true; #endif #ifdef USE_BINARY_SENSOR - "binary_sensor", + if (match.domain_equals(ESPHOME_F("binary_sensor"))) + return true; #endif #ifdef USE_TEXT_SENSOR - "text_sensor", + if (match.domain_equals(ESPHOME_F("text_sensor"))) + return true; #endif #ifdef USE_EVENT - "event", + if (match.domain_equals(ESPHOME_F("event"))) + return true; #endif - }; + } - static const char *const GET_POST_DOMAINS[] = { + // Check GET+POST domains + if (is_get_or_post) { #ifdef USE_SWITCH - "switch", + if (match.domain_equals(ESPHOME_F("switch"))) + return true; #endif #ifdef USE_BUTTON - "button", + if (match.domain_equals(ESPHOME_F("button"))) + return true; #endif #ifdef USE_FAN - "fan", + if (match.domain_equals(ESPHOME_F("fan"))) + return true; #endif #ifdef USE_LIGHT - "light", + if (match.domain_equals(ESPHOME_F("light"))) + return true; #endif #ifdef USE_COVER - "cover", + if (match.domain_equals(ESPHOME_F("cover"))) + return true; #endif #ifdef USE_NUMBER - "number", + if (match.domain_equals(ESPHOME_F("number"))) + return true; #endif #ifdef USE_DATETIME_DATE - "date", + if (match.domain_equals(ESPHOME_F("date"))) + return true; #endif #ifdef USE_DATETIME_TIME - "time", + if (match.domain_equals(ESPHOME_F("time"))) + return true; #endif #ifdef USE_DATETIME_DATETIME - "datetime", + if (match.domain_equals(ESPHOME_F("datetime"))) + return true; #endif #ifdef USE_TEXT - "text", + if (match.domain_equals(ESPHOME_F("text"))) + return true; #endif #ifdef USE_SELECT - "select", + if (match.domain_equals(ESPHOME_F("select"))) + return true; #endif #ifdef USE_CLIMATE - "climate", + if (match.domain_equals(ESPHOME_F("climate"))) + return true; #endif #ifdef USE_LOCK - "lock", + if (match.domain_equals(ESPHOME_F("lock"))) + return true; #endif #ifdef USE_VALVE - "valve", + if (match.domain_equals(ESPHOME_F("valve"))) + return true; #endif #ifdef USE_ALARM_CONTROL_PANEL - "alarm_control_panel", + if (match.domain_equals(ESPHOME_F("alarm_control_panel"))) + return true; #endif #ifdef USE_UPDATE - "update", + if (match.domain_equals(ESPHOME_F("update"))) + return true; #endif #ifdef USE_WATER_HEATER - "water_heater", + if (match.domain_equals(ESPHOME_F("water_heater"))) + return true; #endif #ifdef USE_INFRARED - "infrared", + if (match.domain_equals(ESPHOME_F("infrared"))) + return true; #endif - }; - - // Check GET-only domains - if (is_get) { - for (const auto &domain : GET_ONLY_DOMAINS) { - if (match.domain_equals(domain)) - return true; - } - } - - // Check GET+POST domains - if (is_get_or_post) { - for (const auto &domain : GET_POST_DOMAINS) { - if (match.domain_equals(domain)) - return true; - } } return false; From 4d4283bcfa1fa9f7d1139a45206418b476b07a7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 15:50:23 -1000 Subject: [PATCH 05/51] [udp] Store addresses in flash instead of heap (#13330) --- esphome/components/udp/__init__.py | 3 +- esphome/components/udp/udp_component.cpp | 12 +- esphome/components/udp/udp_component.h | 14 +- tests/components/udp/common.yaml | 5 +- .../fixtures/udp_send_receive.yaml | 33 ++++ tests/integration/test_udp.py | 171 ++++++++++++++++++ 6 files changed, 222 insertions(+), 16 deletions(-) create mode 100644 tests/integration/fixtures/udp_send_receive.yaml create mode 100644 tests/integration/test_udp.py diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 69abf4b989e6..9be196d4207d 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -108,8 +108,7 @@ async def to_code(config): cg.add(var.set_broadcast_port(conf_port[CONF_BROADCAST_PORT])) if (listen_address := str(config[CONF_LISTEN_ADDRESS])) != "255.255.255.255": cg.add(var.set_listen_address(listen_address)) - for address in config[CONF_ADDRESSES]: - cg.add(var.add_address(str(address))) + cg.add(var.set_addresses([str(addr) for addr in config[CONF_ADDRESSES]])) if on_receive := config.get(CONF_ON_RECEIVE): on_receive = on_receive[0] trigger = cg.new_Pvariable(on_receive[CONF_TRIGGER_ID]) diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 4474efeb776f..947a59dfa93c 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -5,8 +5,7 @@ #include "esphome/components/network/util.h" #include "udp_component.h" -namespace esphome { -namespace udp { +namespace esphome::udp { static const char *const TAG = "udp"; @@ -95,7 +94,7 @@ void UDPComponent::setup() { // 8266 and RP2040 `Duino for (const auto &address : this->addresses_) { auto ipaddr = IPAddress(); - ipaddr.fromString(address.c_str()); + ipaddr.fromString(address); this->ipaddrs_.push_back(ipaddr); } if (this->should_listen_) @@ -130,8 +129,8 @@ void UDPComponent::dump_config() { " Listen Port: %u\n" " Broadcast Port: %u", this->listen_port_, this->broadcast_port_); - for (const auto &address : this->addresses_) - ESP_LOGCONFIG(TAG, " Address: %s", address.c_str()); + for (const char *address : this->addresses_) + ESP_LOGCONFIG(TAG, " Address: %s", address); if (this->listen_address_.has_value()) { char addr_buf[network::IP_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, " Listen address: %s", this->listen_address_.value().str_to(addr_buf)); @@ -162,7 +161,6 @@ void UDPComponent::send_packet(const uint8_t *data, size_t size) { } #endif } -} // namespace udp -} // namespace esphome +} // namespace esphome::udp #endif diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index 065789ae28df..9967e4dbbb73 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -2,6 +2,7 @@ #include "esphome/core/defines.h" #ifdef USE_NETWORK +#include "esphome/core/helpers.h" #include "esphome/components/network/ip_address.h" #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #include "esphome/components/socket/socket.h" @@ -9,15 +10,17 @@ #ifdef USE_SOCKET_IMPL_LWIP_TCP #include #endif +#include #include -namespace esphome { -namespace udp { +namespace esphome::udp { static const size_t MAX_PACKET_SIZE = 508; class UDPComponent : public Component { public: - void add_address(const char *addr) { this->addresses_.emplace_back(addr); } + void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } + /// Prevent accidental use of std::string which would dangle + void set_addresses(std::initializer_list addresses) = delete; void set_listen_address(const char *listen_addr) { this->listen_address_ = network::IPAddress(listen_addr); } void set_listen_port(uint16_t port) { this->listen_port_ = port; } void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; } @@ -49,11 +52,10 @@ class UDPComponent : public Component { std::vector ipaddrs_{}; WiFiUDP udp_client_{}; #endif - std::vector addresses_{}; + FixedVector addresses_{}; optional listen_address_{}; }; -} // namespace udp -} // namespace esphome +} // namespace esphome::udp #endif diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index 98546d49ef55..3466e8d2ee0a 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -5,7 +5,10 @@ wifi: udp: id: my_udp listen_address: 239.0.60.53 - addresses: ["239.0.60.53"] + addresses: + - "239.0.60.53" + - "192.168.1.255" + - "10.0.0.255" on_receive: - logger.log: format: "Received %d bytes" diff --git a/tests/integration/fixtures/udp_send_receive.yaml b/tests/integration/fixtures/udp_send_receive.yaml new file mode 100644 index 000000000000..155d93272264 --- /dev/null +++ b/tests/integration/fixtures/udp_send_receive.yaml @@ -0,0 +1,33 @@ +esphome: + name: udp-test + +host: + +api: + services: + - service: send_udp_message + then: + - udp.write: + id: test_udp + data: "HELLO_UDP_TEST" + - service: send_udp_bytes + then: + - udp.write: + id: test_udp + data: [0x55, 0x44, 0x50, 0x5F, 0x42, 0x59, 0x54, 0x45, 0x53] # "UDP_BYTES" + +logger: + level: DEBUG + +udp: + - id: test_udp + addresses: + - "127.0.0.1" + - "127.0.0.2" + port: + listen_port: UDP_LISTEN_PORT_PLACEHOLDER + broadcast_port: UDP_BROADCAST_PORT_PLACEHOLDER + on_receive: + - logger.log: + format: "Received UDP: %d bytes" + args: [data.size()] diff --git a/tests/integration/test_udp.py b/tests/integration/test_udp.py new file mode 100644 index 000000000000..74c7ef60e340 --- /dev/null +++ b/tests/integration/test_udp.py @@ -0,0 +1,171 @@ +"""Integration test for UDP component.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +import contextlib +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +import socket + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@dataclass +class UDPReceiver: + """Collects UDP messages received.""" + + messages: list[bytes] = field(default_factory=list) + message_received: asyncio.Event = field(default_factory=asyncio.Event) + + def on_message(self, data: bytes) -> None: + """Called when a message is received.""" + self.messages.append(data) + self.message_received.set() + + async def wait_for_message(self, timeout: float = 5.0) -> bytes: + """Wait for a message to be received.""" + await asyncio.wait_for(self.message_received.wait(), timeout=timeout) + return self.messages[-1] + + async def wait_for_content(self, content: bytes, timeout: float = 5.0) -> bytes: + """Wait for a specific message content.""" + deadline = asyncio.get_event_loop().time() + timeout + while True: + for msg in self.messages: + if content in msg: + return msg + remaining = deadline - asyncio.get_event_loop().time() + if remaining <= 0: + raise TimeoutError( + f"Content {content!r} not found in messages: {self.messages}" + ) + try: + await asyncio.wait_for(self.message_received.wait(), timeout=remaining) + self.message_received.clear() + except TimeoutError: + raise TimeoutError( + f"Content {content!r} not found in messages: {self.messages}" + ) from None + + +@asynccontextmanager +async def udp_listener(port: int = 0) -> AsyncGenerator[tuple[int, UDPReceiver]]: + """Async context manager that listens for UDP messages. + + Args: + port: Port to listen on. 0 for auto-assign. + + Yields: + Tuple of (port, UDPReceiver) where port is the UDP port being listened on. + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", port)) + sock.setblocking(False) + actual_port = sock.getsockname()[1] + + receiver = UDPReceiver() + + async def receive_messages() -> None: + """Background task to receive UDP messages.""" + loop = asyncio.get_running_loop() + while True: + try: + data = await loop.sock_recv(sock, 4096) + if data: + receiver.on_message(data) + except BlockingIOError: + await asyncio.sleep(0.01) + except Exception: + break + + task = asyncio.create_task(receive_messages()) + try: + yield actual_port, receiver + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + sock.close() + + +@pytest.mark.asyncio +async def test_udp_send_receive( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test UDP component can send messages with multiple addresses configured.""" + # Track log lines to verify dump_config output + log_lines: list[str] = [] + + def on_log_line(line: str) -> None: + log_lines.append(line) + + async with udp_listener() as (udp_port, receiver): + # Replace placeholders in the config + config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(udp_port + 1)) + config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(udp_port)) + + async with ( + run_compiled(config, line_callback=on_log_line), + api_client_connected() as client, + ): + # Verify device is running + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "udp-test" + + # Get services + _, services = await client.list_entities_services() + + # Test sending string message + send_message_service = next( + (s for s in services if s.name == "send_udp_message"), None + ) + assert send_message_service is not None, ( + "send_udp_message service not found" + ) + + await client.execute_service(send_message_service, {}) + + try: + msg = await receiver.wait_for_content(b"HELLO_UDP_TEST", timeout=5.0) + assert b"HELLO_UDP_TEST" in msg + except TimeoutError: + pytest.fail( + f"UDP string message not received. Got: {receiver.messages}" + ) + + # Test sending bytes + send_bytes_service = next( + (s for s in services if s.name == "send_udp_bytes"), None + ) + assert send_bytes_service is not None, "send_udp_bytes service not found" + + await client.execute_service(send_bytes_service, {}) + + try: + msg = await receiver.wait_for_content(b"UDP_BYTES", timeout=5.0) + assert b"UDP_BYTES" in msg + except TimeoutError: + pytest.fail(f"UDP bytes message not received. Got: {receiver.messages}") + + # Verify we received at least 2 messages (string + bytes) + assert len(receiver.messages) >= 2, ( + f"Expected at least 2 messages, got {len(receiver.messages)}" + ) + + # Verify dump_config logged all configured addresses + # This tests that FixedVector stores addresses correctly + log_text = "\n".join(log_lines) + assert "Address: 127.0.0.1" in log_text, ( + f"Address 127.0.0.1 not found in dump_config. Log: {log_text[-2000:]}" + ) + assert "Address: 127.0.0.2" in log_text, ( + f"Address 127.0.0.2 not found in dump_config. Log: {log_text[-2000:]}" + ) From 0a1e7ee50b1eae6a32e1318a3cf34b6cd5d2f1f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 15:50:42 -1000 Subject: [PATCH 06/51] [pipsolar] Store command strings in flash (#13336) --- .../components/pipsolar/output/pipsolar_output.cpp | 2 +- .../components/pipsolar/output/pipsolar_output.h | 8 +++++--- .../components/pipsolar/switch/pipsolar_switch.cpp | 11 +++-------- .../components/pipsolar/switch/pipsolar_switch.h | 13 ++++++++----- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/esphome/components/pipsolar/output/pipsolar_output.cpp b/esphome/components/pipsolar/output/pipsolar_output.cpp index 163fbf4eb2a5..ebfb9a7bbc9c 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.cpp +++ b/esphome/components/pipsolar/output/pipsolar_output.cpp @@ -9,7 +9,7 @@ static const char *const TAG = "pipsolar.output"; void PipsolarOutput::write_state(float state) { char tmp[10]; - sprintf(tmp, this->set_command_.c_str(), state); + snprintf(tmp, sizeof(tmp), this->set_command_, state); if (std::find(this->possible_values_.begin(), this->possible_values_.end(), state) != this->possible_values_.end()) { ESP_LOGD(TAG, "Will write: %s out of value %f / %02.0f", tmp, state, state); diff --git a/esphome/components/pipsolar/output/pipsolar_output.h b/esphome/components/pipsolar/output/pipsolar_output.h index b4b8000962cb..66eda8e39168 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.h +++ b/esphome/components/pipsolar/output/pipsolar_output.h @@ -15,13 +15,15 @@ class PipsolarOutput : public output::FloatOutput { public: PipsolarOutput() {} void set_parent(Pipsolar *parent) { this->parent_ = parent; } - void set_set_command(const std::string &command) { this->set_command_ = command; }; + void set_set_command(const char *command) { this->set_command_ = command; } + /// Prevent accidental use of std::string which would dangle + void set_set_command(const std::string &command) = delete; void set_possible_values(std::vector possible_values) { this->possible_values_ = std::move(possible_values); } - void set_value(float value) { this->write_state(value); }; + void set_value(float value) { this->write_state(value); } protected: void write_state(float state) override; - std::string set_command_; + const char *set_command_{nullptr}; Pipsolar *parent_; std::vector possible_values_; }; diff --git a/esphome/components/pipsolar/switch/pipsolar_switch.cpp b/esphome/components/pipsolar/switch/pipsolar_switch.cpp index 649d95161865..512587511b13 100644 --- a/esphome/components/pipsolar/switch/pipsolar_switch.cpp +++ b/esphome/components/pipsolar/switch/pipsolar_switch.cpp @@ -9,14 +9,9 @@ static const char *const TAG = "pipsolar.switch"; void PipsolarSwitch::dump_config() { LOG_SWITCH("", "Pipsolar Switch", this); } void PipsolarSwitch::write_state(bool state) { - if (state) { - if (!this->on_command_.empty()) { - this->parent_->queue_command(this->on_command_); - } - } else { - if (!this->off_command_.empty()) { - this->parent_->queue_command(this->off_command_); - } + const char *command = state ? this->on_command_ : this->off_command_; + if (command != nullptr) { + this->parent_->queue_command(command); } } diff --git a/esphome/components/pipsolar/switch/pipsolar_switch.h b/esphome/components/pipsolar/switch/pipsolar_switch.h index 11ff6c853ad3..bb62d4794a54 100644 --- a/esphome/components/pipsolar/switch/pipsolar_switch.h +++ b/esphome/components/pipsolar/switch/pipsolar_switch.h @@ -9,15 +9,18 @@ namespace pipsolar { class Pipsolar; class PipsolarSwitch : public switch_::Switch, public Component { public: - void set_parent(Pipsolar *parent) { this->parent_ = parent; }; - void set_on_command(const std::string &command) { this->on_command_ = command; }; - void set_off_command(const std::string &command) { this->off_command_ = command; }; + void set_parent(Pipsolar *parent) { this->parent_ = parent; } + void set_on_command(const char *command) { this->on_command_ = command; } + void set_off_command(const char *command) { this->off_command_ = command; } + /// Prevent accidental use of std::string which would dangle + void set_on_command(const std::string &command) = delete; + void set_off_command(const std::string &command) = delete; void dump_config() override; protected: void write_state(bool state) override; - std::string on_command_; - std::string off_command_; + const char *on_command_{nullptr}; + const char *off_command_{nullptr}; Pipsolar *parent_; }; From ee2a81923b4900edd3d8156f3049690af07c8e53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 15:51:01 -1000 Subject: [PATCH 07/51] [sun] Store text sensor format string in flash (#13335) --- esphome/components/sun/text_sensor/sun_text_sensor.h | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/sun/text_sensor/sun_text_sensor.h b/esphome/components/sun/text_sensor/sun_text_sensor.h index 9345a32223c6..c3b60ffd65e7 100644 --- a/esphome/components/sun/text_sensor/sun_text_sensor.h +++ b/esphome/components/sun/text_sensor/sun_text_sensor.h @@ -14,7 +14,9 @@ class SunTextSensor : public text_sensor::TextSensor, public PollingComponent { void set_parent(Sun *parent) { parent_ = parent; } void set_elevation(double elevation) { elevation_ = elevation; } void set_sunrise(bool sunrise) { sunrise_ = sunrise; } - void set_format(const std::string &format) { format_ = format; } + void set_format(const char *format) { this->format_ = format; } + /// Prevent accidental use of std::string which would dangle + void set_format(const std::string &format) = delete; void update() override { optional res; @@ -29,14 +31,14 @@ class SunTextSensor : public text_sensor::TextSensor, public PollingComponent { } char buf[ESPTime::STRFTIME_BUFFER_SIZE]; - size_t len = res->strftime_to(buf, this->format_.c_str()); + size_t len = res->strftime_to(buf, this->format_); this->publish_state(buf, len); } void dump_config() override; protected: - std::string format_{}; + const char *format_{nullptr}; Sun *parent_; double elevation_; bool sunrise_; From ed58b9372f90e834e23f1a86e6c5320b8da4bb67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 15:51:12 -1000 Subject: [PATCH 08/51] [template] Store text initial_value in flash and avoid heap allocation in setup (#13332) --- .../template/text/template_text.cpp | 25 ++++++++++++------- .../components/template/text/template_text.h | 6 +++-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index 32ed8f047bd4..5acbb6e15ac0 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -8,16 +8,23 @@ static const char *const TAG = "template.text"; void TemplateText::setup() { if (this->f_.has_value()) return; - std::string value = this->initial_value_; - if (!this->pref_) { - ESP_LOGD(TAG, "State from initial: %s", value.c_str()); - } else { - uint32_t key = this->get_preference_hash(); - key += this->traits.get_min_length() << 2; - key += this->traits.get_max_length() << 4; - key += fnv1_hash(this->traits.get_pattern_c_str()) << 6; - this->pref_->setup(key, value); + + if (this->pref_ == nullptr) { + // No restore - use const char* directly, no heap allocation needed + if (this->initial_value_ != nullptr && this->initial_value_[0] != '\0') { + ESP_LOGD(TAG, "State from initial: %s", this->initial_value_); + this->publish_state(this->initial_value_); + } + return; } + + // Need std::string for pref_->setup() to fill from flash + std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""}; + uint32_t key = this->get_preference_hash(); + key += this->traits.get_min_length() << 2; + key += this->traits.get_max_length() << 4; + key += fnv1_hash(this->traits.get_pattern_c_str()) << 6; + this->pref_->setup(key, value); if (!value.empty()) this->publish_state(value); } diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index 178b410ed29b..e5e5e4f4a8b2 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -70,13 +70,15 @@ class TemplateText final : public text::Text, public PollingComponent { Trigger *get_set_trigger() const { return this->set_trigger_; } void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } - void set_initial_value(const std::string &initial_value) { this->initial_value_ = initial_value; } + void set_initial_value(const char *initial_value) { this->initial_value_ = initial_value; } + /// Prevent accidental use of std::string which would dangle + void set_initial_value(const std::string &initial_value) = delete; void set_value_saver(TemplateTextSaverBase *restore_value_saver) { this->pref_ = restore_value_saver; } protected: void control(const std::string &value) override; bool optimistic_ = false; - std::string initial_value_; + const char *initial_value_{nullptr}; Trigger *set_trigger_ = new Trigger(); TemplateLambda f_{}; From 4cc0f874f75b1d83245ae2de67512df1ea385223 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 15:51:26 -1000 Subject: [PATCH 09/51] [wireguard] Store configuration strings in flash instead of heap (#13331) --- esphome/components/wireguard/__init__.py | 15 ++++- esphome/components/wireguard/wireguard.cpp | 71 ++++++++++++---------- esphome/components/wireguard/wireguard.h | 63 ++++++++++++------- 3 files changed, 92 insertions(+), 57 deletions(-) diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 50c79802153f..124d9a8c3281 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -30,6 +30,7 @@ wireguard_ns = cg.esphome_ns.namespace("wireguard") Wireguard = wireguard_ns.class_("Wireguard", cg.Component, cg.PollingComponent) +AllowedIP = wireguard_ns.struct("AllowedIP") WireguardPeerOnlineCondition = wireguard_ns.class_( "WireguardPeerOnlineCondition", automation.Condition ) @@ -108,8 +109,18 @@ async def to_code(config): ) ) - for ip in allowed_ips: - cg.add(var.add_allowed_ip(str(ip.network_address), str(ip.netmask))) + cg.add( + var.set_allowed_ips( + [ + cg.StructInitializer( + AllowedIP, + ("ip", str(ip.network_address)), + ("netmask", str(ip.netmask)), + ) + for ip in allowed_ips + ] + ) + ) cg.add(var.set_srctime(await cg.get_variable(config[CONF_TIME_ID]))) diff --git a/esphome/components/wireguard/wireguard.cpp b/esphome/components/wireguard/wireguard.cpp index 7810a40ae1d8..2022e25b6cf4 100644 --- a/esphome/components/wireguard/wireguard.cpp +++ b/esphome/components/wireguard/wireguard.cpp @@ -13,8 +13,7 @@ #include #include -namespace esphome { -namespace wireguard { +namespace esphome::wireguard { static const char *const TAG = "wireguard"; @@ -28,16 +27,16 @@ static const char *const LOGMSG_ONLINE = "online"; static const char *const LOGMSG_OFFLINE = "offline"; void Wireguard::setup() { - this->wg_config_.address = this->address_.c_str(); - this->wg_config_.private_key = this->private_key_.c_str(); - this->wg_config_.endpoint = this->peer_endpoint_.c_str(); - this->wg_config_.public_key = this->peer_public_key_.c_str(); + this->wg_config_.address = this->address_; + this->wg_config_.private_key = this->private_key_; + this->wg_config_.endpoint = this->peer_endpoint_; + this->wg_config_.public_key = this->peer_public_key_; this->wg_config_.port = this->peer_port_; - this->wg_config_.netmask = this->netmask_.c_str(); + this->wg_config_.netmask = this->netmask_; this->wg_config_.persistent_keepalive = this->keepalive_; - if (!this->preshared_key_.empty()) - this->wg_config_.preshared_key = this->preshared_key_.c_str(); + if (this->preshared_key_ != nullptr) + this->wg_config_.preshared_key = this->preshared_key_; this->publish_enabled_state(); @@ -131,6 +130,10 @@ void Wireguard::update() { } void Wireguard::dump_config() { + char private_key_masked[MASK_KEY_BUFFER_SIZE]; + char preshared_key_masked[MASK_KEY_BUFFER_SIZE]; + mask_key_to(private_key_masked, sizeof(private_key_masked), this->private_key_); + mask_key_to(preshared_key_masked, sizeof(preshared_key_masked), this->preshared_key_); // clang-format off ESP_LOGCONFIG( TAG, @@ -142,13 +145,13 @@ void Wireguard::dump_config() { " Peer Port: " LOG_SECRET("%d") "\n" " Peer Public Key: " LOG_SECRET("%s") "\n" " Peer Pre-shared Key: " LOG_SECRET("%s"), - this->address_.c_str(), this->netmask_.c_str(), mask_key(this->private_key_).c_str(), - this->peer_endpoint_.c_str(), this->peer_port_, this->peer_public_key_.c_str(), - (!this->preshared_key_.empty() ? mask_key(this->preshared_key_).c_str() : "NOT IN USE")); + this->address_, this->netmask_, private_key_masked, + this->peer_endpoint_, this->peer_port_, this->peer_public_key_, + (this->preshared_key_ != nullptr ? preshared_key_masked : "NOT IN USE")); // clang-format on ESP_LOGCONFIG(TAG, " Peer Allowed IPs:"); - for (auto &allowed_ip : this->allowed_ips_) { - ESP_LOGCONFIG(TAG, " - %s/%s", std::get<0>(allowed_ip).c_str(), std::get<1>(allowed_ip).c_str()); + for (const AllowedIP &allowed_ip : this->allowed_ips_) { + ESP_LOGCONFIG(TAG, " - %s/%s", allowed_ip.ip, allowed_ip.netmask); } ESP_LOGCONFIG(TAG, " Peer Persistent Keepalive: %d%s", this->keepalive_, (this->keepalive_ > 0 ? "s" : " (DISABLED)")); @@ -176,18 +179,6 @@ time_t Wireguard::get_latest_handshake() const { return result; } -void Wireguard::set_address(const std::string &address) { this->address_ = address; } -void Wireguard::set_netmask(const std::string &netmask) { this->netmask_ = netmask; } -void Wireguard::set_private_key(const std::string &key) { this->private_key_ = key; } -void Wireguard::set_peer_endpoint(const std::string &endpoint) { this->peer_endpoint_ = endpoint; } -void Wireguard::set_peer_public_key(const std::string &key) { this->peer_public_key_ = key; } -void Wireguard::set_peer_port(const uint16_t port) { this->peer_port_ = port; } -void Wireguard::set_preshared_key(const std::string &key) { this->preshared_key_ = key; } - -void Wireguard::add_allowed_ip(const std::string &ip, const std::string &netmask) { - this->allowed_ips_.emplace_back(ip, netmask); -} - void Wireguard::set_keepalive(const uint16_t seconds) { this->keepalive_ = seconds; } void Wireguard::set_reboot_timeout(const uint32_t seconds) { this->reboot_timeout_ = seconds; } void Wireguard::set_srctime(time::RealTimeClock *srctime) { this->srctime_ = srctime; } @@ -274,9 +265,8 @@ void Wireguard::start_connection_() { ESP_LOGD(TAG, "Configuring allowed IPs list"); bool allowed_ips_ok = true; - for (std::tuple ip : this->allowed_ips_) { - allowed_ips_ok &= - (esp_wireguard_add_allowed_ip(&(this->wg_ctx_), std::get<0>(ip).c_str(), std::get<1>(ip).c_str()) == ESP_OK); + for (const AllowedIP &ip : this->allowed_ips_) { + allowed_ips_ok &= (esp_wireguard_add_allowed_ip(&(this->wg_ctx_), ip.ip, ip.netmask) == ESP_OK); } if (allowed_ips_ok) { @@ -299,8 +289,25 @@ void Wireguard::stop_connection_() { } } -std::string mask_key(const std::string &key) { return (key.substr(0, 5) + "[...]="); } +void mask_key_to(char *buffer, size_t len, const char *key) { + // Format: "XXXXX[...]=\0" = MASK_KEY_BUFFER_SIZE chars minimum + if (len < MASK_KEY_BUFFER_SIZE || key == nullptr) { + if (len > 0) + buffer[0] = '\0'; + return; + } + // Copy first 5 characters of the key + size_t i = 0; + for (; i < 5 && key[i] != '\0'; ++i) { + buffer[i] = key[i]; + } + // Append "[...]=" + const char *suffix = "[...]="; + for (size_t j = 0; suffix[j] != '\0' && (i + j) < len - 1; ++j) { + buffer[i + j] = suffix[j]; + } + buffer[i + 6] = '\0'; +} -} // namespace wireguard -} // namespace esphome +} // namespace esphome::wireguard #endif diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index f8f79b835d8d..e8470c75cd9c 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -2,10 +2,10 @@ #include "esphome/core/defines.h" #ifdef USE_WIREGUARD #include -#include -#include +#include #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/components/time/real_time_clock.h" #ifdef USE_BINARY_SENSOR @@ -22,8 +22,13 @@ #include -namespace esphome { -namespace wireguard { +namespace esphome::wireguard { + +/// Allowed IP entry for WireGuard peer configuration. +struct AllowedIP { + const char *ip; + const char *netmask; +}; /// Main Wireguard component class. class Wireguard : public PollingComponent { @@ -37,15 +42,25 @@ class Wireguard : public PollingComponent { float get_setup_priority() const override { return esphome::setup_priority::BEFORE_CONNECTION; } - void set_address(const std::string &address); - void set_netmask(const std::string &netmask); - void set_private_key(const std::string &key); - void set_peer_endpoint(const std::string &endpoint); - void set_peer_public_key(const std::string &key); - void set_peer_port(uint16_t port); - void set_preshared_key(const std::string &key); - - void add_allowed_ip(const std::string &ip, const std::string &netmask); + void set_address(const char *address) { this->address_ = address; } + void set_netmask(const char *netmask) { this->netmask_ = netmask; } + void set_private_key(const char *key) { this->private_key_ = key; } + void set_peer_endpoint(const char *endpoint) { this->peer_endpoint_ = endpoint; } + void set_peer_public_key(const char *key) { this->peer_public_key_ = key; } + void set_peer_port(uint16_t port) { this->peer_port_ = port; } + void set_preshared_key(const char *key) { this->preshared_key_ = key; } + + /// Prevent accidental use of std::string which would dangle + void set_address(const std::string &address) = delete; + void set_netmask(const std::string &netmask) = delete; + void set_private_key(const std::string &key) = delete; + void set_peer_endpoint(const std::string &endpoint) = delete; + void set_peer_public_key(const std::string &key) = delete; + void set_preshared_key(const std::string &key) = delete; + + void set_allowed_ips(std::initializer_list ips) { this->allowed_ips_ = ips; } + /// Prevent accidental use of std::string which would dangle + void set_allowed_ips(std::initializer_list> ips) = delete; void set_keepalive(uint16_t seconds); void set_reboot_timeout(uint32_t seconds); @@ -83,14 +98,14 @@ class Wireguard : public PollingComponent { time_t get_latest_handshake() const; protected: - std::string address_; - std::string netmask_; - std::string private_key_; - std::string peer_endpoint_; - std::string peer_public_key_; - std::string preshared_key_; + const char *address_{nullptr}; + const char *netmask_{nullptr}; + const char *private_key_{nullptr}; + const char *peer_endpoint_{nullptr}; + const char *peer_public_key_{nullptr}; + const char *preshared_key_{nullptr}; - std::vector> allowed_ips_; + FixedVector allowed_ips_; uint16_t peer_port_; uint16_t keepalive_; @@ -142,8 +157,11 @@ class Wireguard : public PollingComponent { void suspend_wdt(); void resume_wdt(); +/// Size of buffer required for mask_key_to: 5 chars + "[...]=" + null = 12 +static constexpr size_t MASK_KEY_BUFFER_SIZE = 12; + /// Strip most part of the key only for secure printing -std::string mask_key(const std::string &key); +void mask_key_to(char *buffer, size_t len, const char *key); /// Condition to check if remote peer is online. template class WireguardPeerOnlineCondition : public Condition, public Parented { @@ -169,6 +187,5 @@ template class WireguardDisableAction : public Action, pu void play(const Ts &...x) override { this->parent_->disable(); } }; -} // namespace wireguard -} // namespace esphome +} // namespace esphome::wireguard #endif From d6a0c8ffbb5859fb2f02b583657228a6343919d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 15:52:06 -1000 Subject: [PATCH 10/51] [template] Store alarm control panel codes in flash instead of heap (#13329) --- .../template/alarm_control_panel/__init__.py | 3 +-- .../template_alarm_control_panel.cpp | 8 +++++++- .../template_alarm_control_panel.h | 14 +++++++++----- tests/components/alarm_control_panel/common.yaml | 3 +++ 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/esphome/components/template/alarm_control_panel/__init__.py b/esphome/components/template/alarm_control_panel/__init__.py index 256c7f276a2e..59624a5f53f2 100644 --- a/esphome/components/template/alarm_control_panel/__init__.py +++ b/esphome/components/template/alarm_control_panel/__init__.py @@ -118,8 +118,7 @@ async def to_code(config): var = await alarm_control_panel.new_alarm_control_panel(config) await cg.register_component(var, config) if CONF_CODES in config: - for acode in config[CONF_CODES]: - cg.add(var.add_code(acode)) + cg.add(var.set_codes(config[CONF_CODES])) if CONF_REQUIRES_CODE_TO_ARM in config: cg.add(var.set_requires_code_to_arm(config[CONF_REQUIRES_CODE_TO_ARM])) diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index 50e43da8d583..028d6f08796f 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -206,7 +206,13 @@ bool TemplateAlarmControlPanel::is_code_valid_(optional code) { if (!this->codes_.empty()) { if (code.has_value()) { ESP_LOGVV(TAG, "Checking code: %s", code.value().c_str()); - return (std::count(this->codes_.begin(), this->codes_.end(), code.value()) == 1); + // Use strcmp for const char* comparison + const char *code_cstr = code.value().c_str(); + for (const char *stored_code : this->codes_) { + if (strcmp(stored_code, code_cstr) == 0) + return true; + } + return false; } ESP_LOGD(TAG, "No code provided"); return false; diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index 2038d8f1b068..df3b64fb6e93 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "esphome/core/automation.h" @@ -86,11 +87,14 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl AlarmSensorType type = ALARM_SENSOR_TYPE_DELAYED); #endif - /** add a code + /** Set the codes (from initializer list). * - * @param code The code + * @param codes The list of valid codes */ - void add_code(const std::string &code) { this->codes_.push_back(code); } + void set_codes(std::initializer_list codes) { this->codes_ = codes; } + + // Deleted overload to catch incorrect std::string usage at compile time + void set_codes(std::initializer_list codes) = delete; /** set requires a code to arm * @@ -155,8 +159,8 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl uint32_t pending_time_; // the time in trigger uint32_t trigger_time_; - // a list of codes - std::vector codes_; + // a list of codes (const char* pointers to string literals in flash) + FixedVector codes_; // requires a code to arm bool requires_code_to_arm_ = false; bool supports_arm_home_ = false; diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 142bf3c7e610..39d5739255e5 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -9,6 +9,8 @@ alarm_control_panel: name: Alarm Panel codes: - "1234" + - "5678" + - "0000" requires_code_to_arm: true arming_home_time: 1s arming_night_time: 1s @@ -29,6 +31,7 @@ alarm_control_panel: name: Alarm Panel 2 codes: - "1234" + - "9999" requires_code_to_arm: true arming_home_time: 1s arming_night_time: 1s From 01cdc4ed58465610cf307b48ed5573834190e837 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 15:52:19 -1000 Subject: [PATCH 11/51] [core] Add fnv1_hash_extend() string overloads, use in atm90e32 (#13326) --- esphome/components/atm90e32/atm90e32.cpp | 9 ++++++--- esphome/core/helpers.h | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index 634260b5e9f6..f4c199cb987a 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -158,12 +158,14 @@ void ATM90E32Component::setup() { if (this->enable_offset_calibration_) { // Initialize flash storage for offset calibrations - uint32_t o_hash = fnv1_hash(std::string("_offset_calibration_") + this->cs_summary_); + uint32_t o_hash = fnv1_hash("_offset_calibration_"); + o_hash = fnv1_hash_extend(o_hash, this->cs_summary_); this->offset_pref_ = global_preferences->make_preference(o_hash, true); this->restore_offset_calibrations_(); // Initialize flash storage for power offset calibrations - uint32_t po_hash = fnv1_hash(std::string("_power_offset_calibration_") + this->cs_summary_); + uint32_t po_hash = fnv1_hash("_power_offset_calibration_"); + po_hash = fnv1_hash_extend(po_hash, this->cs_summary_); this->power_offset_pref_ = global_preferences->make_preference(po_hash, true); this->restore_power_offset_calibrations_(); } else { @@ -183,7 +185,8 @@ void ATM90E32Component::setup() { if (this->enable_gain_calibration_) { // Initialize flash storage for gain calibration - uint32_t g_hash = fnv1_hash(std::string("_gain_calibration_") + this->cs_summary_); + uint32_t g_hash = fnv1_hash("_gain_calibration_"); + g_hash = fnv1_hash_extend(g_hash, this->cs_summary_); this->gain_calibration_pref_ = global_preferences->make_preference(g_hash, true); this->restore_gain_calibrations_(); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 409c691cb103..cdc051fc90c2 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -395,6 +395,28 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL; /// FNV-1 32-bit prime constexpr uint32_t FNV1_PRIME = 16777619UL; +/// Extend a FNV-1 hash with an integer (hashes each byte). +template constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) { + using UnsignedT = std::make_unsigned_t; + UnsignedT uvalue = static_cast(value); + for (size_t i = 0; i < sizeof(T); i++) { + hash *= FNV1_PRIME; + hash ^= (uvalue >> (i * 8)) & 0xFF; + } + return hash; +} +/// Extend a FNV-1 hash with additional string data. +constexpr uint32_t fnv1_hash_extend(uint32_t hash, const char *str) { + if (str) { + while (*str) { + hash *= FNV1_PRIME; + hash ^= *str++; + } + } + return hash; +} +inline uint32_t fnv1_hash_extend(uint32_t hash, const std::string &str) { return fnv1_hash_extend(hash, str.c_str()); } + /// Extend a FNV-1a hash with additional string data. constexpr uint32_t fnv1a_hash_extend(uint32_t hash, const char *str) { if (str) { From 728236270cdb25f3887f6e2bbf4918d29b0aba68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 15:53:01 -1000 Subject: [PATCH 12/51] [weikai] Replace bitset to_string with format_bin_to (#13297) --- esphome/components/weikai/weikai.cpp | 49 +++++++++++--------- esphome/components/weikai/weikai.h | 1 - esphome/components/weikai_spi/weikai_spi.cpp | 25 +++++----- esphome/components/weikai_spi/weikai_spi.h | 1 - 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/esphome/components/weikai/weikai.cpp b/esphome/components/weikai/weikai.cpp index 3384a0572f10..197b516bb29d 100644 --- a/esphome/components/weikai/weikai.cpp +++ b/esphome/components/weikai/weikai.cpp @@ -4,19 +4,13 @@ /// @details The classes declared in this file can be used by the Weikai family #include "weikai.h" +#include "esphome/core/helpers.h" namespace esphome { namespace weikai { static const char *const TAG = "weikai"; -/// @brief convert an int to binary representation as C++ std::string -/// @param val integer to convert -/// @return a std::string -inline std::string i2s(uint8_t val) { return std::bitset<8>(val).to_string(); } -/// Convert std::string to C string -#define I2S2CS(val) (i2s(val).c_str()) - /// @brief measure the time elapsed between two calls /// @param last_time time of the previous call /// @return the elapsed time in milliseconds @@ -170,17 +164,18 @@ void WeikaiComponent::test_gpio_input_() { static bool init_input{false}; static uint8_t state{0}; uint8_t value; + char bin_buf[9]; // 8 binary digits + null if (!init_input) { init_input = true; // set all pins in input mode this->reg(WKREG_GPDIR, 0) = 0x00; ESP_LOGI(TAG, "initializing all pins to input mode"); state = this->reg(WKREG_GPDAT, 0); - ESP_LOGI(TAG, "initial input data state = %02X (%s)", state, I2S2CS(state)); + ESP_LOGI(TAG, "initial input data state = %02X (%s)", state, format_bin_to(bin_buf, state)); } value = this->reg(WKREG_GPDAT, 0); if (value != state) { - ESP_LOGI(TAG, "Input data changed from %02X to %02X (%s)", state, value, I2S2CS(value)); + ESP_LOGI(TAG, "Input data changed from %02X to %02X (%s)", state, value, format_bin_to(bin_buf, value)); state = value; } } @@ -188,6 +183,7 @@ void WeikaiComponent::test_gpio_input_() { void WeikaiComponent::test_gpio_output_() { static bool init_output{false}; static uint8_t state{0}; + char bin_buf[9]; // 8 binary digits + null if (!init_output) { init_output = true; // set all pins in output mode @@ -198,7 +194,7 @@ void WeikaiComponent::test_gpio_output_() { } state = ~state; this->reg(WKREG_GPDAT, 0) = state; - ESP_LOGI(TAG, "Flipping all outputs to %02X (%s)", state, I2S2CS(state)); + ESP_LOGI(TAG, "Flipping all outputs to %02X (%s)", state, format_bin_to(bin_buf, state)); delay(100); // NOLINT } #endif @@ -208,7 +204,9 @@ void WeikaiComponent::test_gpio_output_() { /////////////////////////////////////////////////////////////////////////////// bool WeikaiComponent::read_pin_val_(uint8_t pin) { this->input_state_ = this->reg(WKREG_GPDAT, 0); - ESP_LOGVV(TAG, "reading input pin %u = %u in_state %s", pin, this->input_state_ & (1 << pin), I2S2CS(input_state_)); + char bin_buf[9]; + ESP_LOGVV(TAG, "reading input pin %u = %u in_state %s", pin, this->input_state_ & (1 << pin), + format_bin_to(bin_buf, this->input_state_)); return this->input_state_ & (1 << pin); } @@ -218,7 +216,9 @@ void WeikaiComponent::write_pin_val_(uint8_t pin, bool value) { } else { this->output_state_ &= ~(1 << pin); } - ESP_LOGVV(TAG, "writing output pin %d with %d out_state %s", pin, uint8_t(value), I2S2CS(this->output_state_)); + char bin_buf[9]; + ESP_LOGVV(TAG, "writing output pin %d with %d out_state %s", pin, uint8_t(value), + format_bin_to(bin_buf, this->output_state_)); this->reg(WKREG_GPDAT, 0) = this->output_state_; } @@ -232,7 +232,8 @@ void WeikaiComponent::set_pin_direction_(uint8_t pin, gpio::Flags flags) { ESP_LOGE(TAG, "pin %d direction invalid", pin); } } - ESP_LOGVV(TAG, "setting pin %d direction to %d pin_config=%s", pin, flags, I2S2CS(this->pin_config_)); + char bin_buf[9]; + ESP_LOGVV(TAG, "setting pin %d direction to %d pin_config=%s", pin, flags, format_bin_to(bin_buf, this->pin_config_)); this->reg(WKREG_GPDIR, 0) = this->pin_config_; // TODO check ~ } @@ -241,7 +242,6 @@ void WeikaiGPIOPin::setup() { flags_ == gpio::FLAG_INPUT ? "Input" : this->flags_ == gpio::FLAG_OUTPUT ? "Output" : "NOT SPECIFIED"); - // ESP_LOGCONFIG(TAG, "Setting GPIO pins mode to '%s' %02X", I2S2CS(this->flags_), this->flags_); this->pin_mode(this->flags_); } @@ -297,8 +297,9 @@ void WeikaiChannel::set_line_param_() { break; // no parity 000x } this->reg(WKREG_LCR) = lcr; // write LCR + char bin_buf[9]; ESP_LOGV(TAG, " line config: %d data_bits, %d stop_bits, parity %s register [%s]", this->data_bits_, - this->stop_bits_, p2s(this->parity_), I2S2CS(lcr)); + this->stop_bits_, p2s(this->parity_), format_bin_to(bin_buf, lcr)); } void WeikaiChannel::set_baudrate_() { @@ -334,7 +335,8 @@ size_t WeikaiChannel::tx_in_fifo_() { if (tfcnt == 0) { uint8_t const fsr = this->reg(WKREG_FSR); if (fsr & FSR_TFFULL) { - ESP_LOGVV(TAG, "tx FIFO full FSR=%s", I2S2CS(fsr)); + char bin_buf[9]; + ESP_LOGVV(TAG, "tx FIFO full FSR=%s", format_bin_to(bin_buf, fsr)); tfcnt = FIFO_SIZE; } } @@ -346,14 +348,15 @@ size_t WeikaiChannel::rx_in_fifo_() { size_t available = this->reg(WKREG_RFCNT); uint8_t const fsr = this->reg(WKREG_FSR); if (fsr & (FSR_RFOE | FSR_RFLB | FSR_RFFE | FSR_RFPE)) { + char bin_buf[9]; if (fsr & FSR_RFOE) - ESP_LOGE(TAG, "Receive data overflow FSR=%s", I2S2CS(fsr)); + ESP_LOGE(TAG, "Receive data overflow FSR=%s", format_bin_to(bin_buf, fsr)); if (fsr & FSR_RFLB) - ESP_LOGE(TAG, "Receive line break FSR=%s", I2S2CS(fsr)); + ESP_LOGE(TAG, "Receive line break FSR=%s", format_bin_to(bin_buf, fsr)); if (fsr & FSR_RFFE) - ESP_LOGE(TAG, "Receive frame error FSR=%s", I2S2CS(fsr)); + ESP_LOGE(TAG, "Receive frame error FSR=%s", format_bin_to(bin_buf, fsr)); if (fsr & FSR_RFPE) - ESP_LOGE(TAG, "Receive parity error FSR=%s", I2S2CS(fsr)); + ESP_LOGE(TAG, "Receive parity error FSR=%s", format_bin_to(bin_buf, fsr)); } if ((available == 0) && (fsr & FSR_RFDAT)) { // here we should be very careful because we can have something like this: @@ -362,11 +365,13 @@ size_t WeikaiChannel::rx_in_fifo_() { // - so to be sure we need to do another read of RFCNT and if it is still zero -> buffer full available = this->reg(WKREG_RFCNT); if (available == 0) { // still zero ? - ESP_LOGV(TAG, "rx FIFO is full FSR=%s", I2S2CS(fsr)); + char bin_buf[9]; + ESP_LOGV(TAG, "rx FIFO is full FSR=%s", format_bin_to(bin_buf, fsr)); available = FIFO_SIZE; } } - ESP_LOGVV(TAG, "rx FIFO contain %d bytes - FSR status=%s", available, I2S2CS(fsr)); + char bin_buf2[9]; + ESP_LOGVV(TAG, "rx FIFO contain %d bytes - FSR status=%s", available, format_bin_to(bin_buf2, fsr)); return available; } diff --git a/esphome/components/weikai/weikai.h b/esphome/components/weikai/weikai.h index a27c14106d5b..4440d9414e15 100644 --- a/esphome/components/weikai/weikai.h +++ b/esphome/components/weikai/weikai.h @@ -8,7 +8,6 @@ /// wk2132_i2c, wk2168_i2c, wk2204_i2c, wk2212_i2c #pragma once -#include #include #include #include "esphome/core/component.h" diff --git a/esphome/components/weikai_spi/weikai_spi.cpp b/esphome/components/weikai_spi/weikai_spi.cpp index 7bcb817f097d..20671a581552 100644 --- a/esphome/components/weikai_spi/weikai_spi.cpp +++ b/esphome/components/weikai_spi/weikai_spi.cpp @@ -10,13 +10,6 @@ namespace weikai_spi { using namespace weikai; static const char *const TAG = "weikai_spi"; -/// @brief convert an int to binary representation as C++ std::string -/// @param val integer to convert -/// @return a std::string -inline std::string i2s(uint8_t val) { return std::bitset<8>(val).to_string(); } -/// Convert std::string to C string -#define I2S2CS(val) (i2s(val).c_str()) - /// @brief measure the time elapsed between two calls /// @param last_time time of the previous call /// @return the elapsed time in microseconds @@ -107,7 +100,8 @@ uint8_t WeikaiRegisterSPI::read_reg() const { spi_comp->write_byte(cmd); uint8_t val = spi_comp->read_byte(); spi_comp->disable(); - ESP_LOGVV(TAG, "WeikaiRegisterSPI::read_reg() cmd=%s(%02X) reg=%s ch=%d buf=%02X", I2S2CS(cmd), cmd, + char bin_buf[9]; + ESP_LOGVV(TAG, "WeikaiRegisterSPI::read_reg() cmd=%s(%02X) reg=%s ch=%d buf=%02X", format_bin_to(bin_buf, cmd), cmd, reg_to_str(this->register_, this->comp_->page1()), this->channel_, val); return val; } @@ -120,8 +114,9 @@ void WeikaiRegisterSPI::read_fifo(uint8_t *data, size_t length) const { spi_comp->read_array(data, length); spi_comp->disable(); #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - ESP_LOGVV(TAG, "WeikaiRegisterSPI::read_fifo() cmd=%s(%02X) ch=%d len=%d buffer", I2S2CS(cmd), cmd, this->channel_, - length); + char bin_buf[9]; + ESP_LOGVV(TAG, "WeikaiRegisterSPI::read_fifo() cmd=%s(%02X) ch=%d len=%d buffer", format_bin_to(bin_buf, cmd), cmd, + this->channel_, length); print_buffer(data, length); #endif } @@ -132,8 +127,9 @@ void WeikaiRegisterSPI::write_reg(uint8_t value) { spi_comp->enable(); spi_comp->write_array(buf, 2); spi_comp->disable(); - ESP_LOGVV(TAG, "WeikaiRegisterSPI::write_reg() cmd=%s(%02X) reg=%s ch=%d buf=%02X", I2S2CS(buf[0]), buf[0], - reg_to_str(this->register_, this->comp_->page1()), this->channel_, buf[1]); + char bin_buf[9]; + ESP_LOGVV(TAG, "WeikaiRegisterSPI::write_reg() cmd=%s(%02X) reg=%s ch=%d buf=%02X", format_bin_to(bin_buf, buf[0]), + buf[0], reg_to_str(this->register_, this->comp_->page1()), this->channel_, buf[1]); } void WeikaiRegisterSPI::write_fifo(uint8_t *data, size_t length) { @@ -145,8 +141,9 @@ void WeikaiRegisterSPI::write_fifo(uint8_t *data, size_t length) { spi_comp->disable(); #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - ESP_LOGVV(TAG, "WeikaiRegisterSPI::write_fifo() cmd=%s(%02X) ch=%d len=%d buffer", I2S2CS(cmd), cmd, this->channel_, - length); + char bin_buf[9]; + ESP_LOGVV(TAG, "WeikaiRegisterSPI::write_fifo() cmd=%s(%02X) ch=%d len=%d buffer", format_bin_to(bin_buf, cmd), cmd, + this->channel_, length); print_buffer(data, length); #endif } diff --git a/esphome/components/weikai_spi/weikai_spi.h b/esphome/components/weikai_spi/weikai_spi.h index dd0dc8d49566..a75b85dc8e28 100644 --- a/esphome/components/weikai_spi/weikai_spi.h +++ b/esphome/components/weikai_spi/weikai_spi.h @@ -6,7 +6,6 @@ /// wk2124_spi, wk2132_spi, wk2168_spi, wk2204_spi, wk2212_spi, #pragma once -#include #include #include "esphome/core/component.h" #include "esphome/components/uart/uart.h" From 21794e28e554155c3e8bc2462ae0aeb956dc2c47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 17:26:51 -1000 Subject: [PATCH 13/51] [modbus_controller] Use stack buffers instead of heap-allocating string helpers (#13221) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../modbus_controller/modbus_controller.h | 15 +++++++++++---- .../text_sensor/modbus_textsensor.cpp | 16 ++++++++++++---- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 6ed05715cb08..fca292656827 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -271,24 +271,31 @@ class ServerRegister { // Formats a raw value into a string representation based on the value type for debugging std::string format_value(int64_t value) const { + // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) + // plus null terminator = 43, rounded to 44 for 4-byte alignment + char buf[44]; switch (this->value_type) { case SensorValueType::U_WORD: case SensorValueType::U_DWORD: case SensorValueType::U_DWORD_R: case SensorValueType::U_QWORD: case SensorValueType::U_QWORD_R: - return std::to_string(static_cast(value)); + buf_append_printf(buf, sizeof(buf), 0, "%" PRIu64, static_cast(value)); + return buf; case SensorValueType::S_WORD: case SensorValueType::S_DWORD: case SensorValueType::S_DWORD_R: case SensorValueType::S_QWORD: case SensorValueType::S_QWORD_R: - return std::to_string(value); + buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + return buf; case SensorValueType::FP32_R: case SensorValueType::FP32: - return str_sprintf("%.1f", bit_cast(static_cast(value))); + buf_append_printf(buf, sizeof(buf), 0, "%.1f", bit_cast(static_cast(value))); + return buf; default: - return std::to_string(value); + buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + return buf; } } diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp index 89e86741b0d0..b26411b72e70 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp @@ -16,12 +16,20 @@ void ModbusTextSensor::parse_and_publish(const std::vector &data) { while ((items_left > 0) && index < data.size()) { uint8_t b = data[index]; switch (this->encode_) { - case RawEncoding::HEXBYTES: - output_str += str_snprintf("%02x", 2, b); + case RawEncoding::HEXBYTES: { + // max 3: 2 hex digits + null + char hex_buf[3]; + snprintf(hex_buf, sizeof(hex_buf), "%02x", b); + output_str += hex_buf; break; - case RawEncoding::COMMA: - output_str += str_sprintf(index != this->offset ? ",%d" : "%d", b); + } + case RawEncoding::COMMA: { + // max 5: optional ','(1) + uint8(3) + null, for both ",%d" and "%d" + char dec_buf[5]; + snprintf(dec_buf, sizeof(dec_buf), index != this->offset ? ",%d" : "%d", b); + output_str += dec_buf; break; + } case RawEncoding::ANSI: if (b < 0x20) break; From db0b32bfc9b15031ee3399ac8c560aaabf42892a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 18:06:54 -1000 Subject: [PATCH 14/51] [network] Fix IPAddress::str_to() to lowercase IPv6 hex digits (#13325) --- esphome/components/network/ip_address.h | 30 +++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index b719d1a70e75..3dfcf0cb6409 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -43,6 +43,14 @@ namespace network { /// Buffer size for IP address string (IPv6 max: 39 chars + null) static constexpr size_t IP_ADDRESS_BUFFER_SIZE = 40; +/// Lowercase hex digits in IP address string (A-F -> a-f for IPv6 per RFC 5952) +inline void lowercase_ip_str(char *buf) { + for (char *p = buf; *p; ++p) { + if (*p >= 'A' && *p <= 'F') + *p += 32; + } +} + struct IPAddress { public: #ifdef USE_HOST @@ -52,10 +60,15 @@ struct IPAddress { } IPAddress(const std::string &in_address) { inet_aton(in_address.c_str(), &ip_addr_); } IPAddress(const ip_addr_t *other_ip) { ip_addr_ = *other_ip; } - std::string str() const { return str_lower_case(inet_ntoa(ip_addr_)); } + std::string str() const { + char buf[IP_ADDRESS_BUFFER_SIZE]; + this->str_to(buf); + return buf; + } /// Write IP address to buffer. Buffer must be at least IP_ADDRESS_BUFFER_SIZE bytes. char *str_to(char *buf) const { - return const_cast(inet_ntop(AF_INET, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE)); + inet_ntop(AF_INET, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE); + return buf; // IPv4 only, no hex letters to lowercase } #else IPAddress() { ip_addr_set_zero(&ip_addr_); } @@ -134,9 +147,18 @@ struct IPAddress { bool is_ip4() const { return IP_IS_V4(&ip_addr_); } bool is_ip6() const { return IP_IS_V6(&ip_addr_); } bool is_multicast() const { return ip_addr_ismulticast(&ip_addr_); } - std::string str() const { return str_lower_case(ipaddr_ntoa(&ip_addr_)); } + std::string str() const { + char buf[IP_ADDRESS_BUFFER_SIZE]; + this->str_to(buf); + return buf; + } /// Write IP address to buffer. Buffer must be at least IP_ADDRESS_BUFFER_SIZE bytes. - char *str_to(char *buf) const { return ipaddr_ntoa_r(&ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE); } + /// Output is lowercased per RFC 5952 (IPv6 hex digits a-f). + char *str_to(char *buf) const { + ipaddr_ntoa_r(&ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE); + lowercase_ip_str(buf); + return buf; + } bool operator==(const IPAddress &other) const { return ip_addr_cmp(&ip_addr_, &other.ip_addr_); } bool operator!=(const IPAddress &other) const { return !ip_addr_cmp(&ip_addr_, &other.ip_addr_); } IPAddress &operator+=(uint8_t increase) { From 680e92a226b7c643157ed2eb7d5d679a95879adc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 08:36:56 -1000 Subject: [PATCH 15/51] [core] Add str_endswith_ignore_case to avoid heap allocation in audio file type detection (#13313) --- esphome/components/audio/audio_reader.cpp | 8 +++----- esphome/core/helpers.cpp | 7 +++++++ esphome/core/helpers.h | 9 +++++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index 7794187a69b1..4e4bd31f9bbf 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -185,18 +185,16 @@ esp_err_t AudioReader::start(const std::string &uri, AudioFileType &file_type) { return err; } - std::string url_string = str_lower_case(url); - - if (str_endswith(url_string, ".wav")) { + if (str_endswith_ignore_case(url, ".wav")) { file_type = AudioFileType::WAV; } #ifdef USE_AUDIO_MP3_SUPPORT - else if (str_endswith(url_string, ".mp3")) { + else if (str_endswith_ignore_case(url, ".mp3")) { file_type = AudioFileType::MP3; } #endif #ifdef USE_AUDIO_FLAC_SUPPORT - else if (str_endswith(url_string, ".flac")) { + else if (str_endswith_ignore_case(url, ".flac")) { file_type = AudioFileType::FLAC; } #endif diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 4e3761675da8..44c20845e8a2 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -174,6 +174,13 @@ bool str_endswith(const std::string &str, const std::string &end) { return str.rfind(end) == (str.size() - end.size()); } #endif + +bool str_endswith_ignore_case(const char *str, size_t str_len, const char *suffix, size_t suffix_len) { + if (suffix_len > str_len) + return false; + return strncasecmp(str + str_len - suffix_len, suffix, suffix_len) == 0; +} + std::string str_truncate(const std::string &str, size_t length) { return str.length() > length ? str.substr(0, length) : str; } diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index cdc051fc90c2..8b581eb97a33 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -545,6 +545,15 @@ bool str_startswith(const std::string &str, const std::string &start); /// Check whether a string ends with a value. bool str_endswith(const std::string &str, const std::string &end); +/// Case-insensitive check if string ends with suffix (no heap allocation). +bool str_endswith_ignore_case(const char *str, size_t str_len, const char *suffix, size_t suffix_len); +inline bool str_endswith_ignore_case(const char *str, const char *suffix) { + return str_endswith_ignore_case(str, strlen(str), suffix, strlen(suffix)); +} +inline bool str_endswith_ignore_case(const std::string &str, const char *suffix) { + return str_endswith_ignore_case(str.c_str(), str.size(), suffix, strlen(suffix)); +} + /// Truncate a string to a specific length. /// @warning Allocates heap memory. Avoid in new code - causes heap fragmentation on long-running devices. std::string str_truncate(const std::string &str, size_t length); From baf2b0e3c9908b0c418f74069d3b1d4836830f6a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:23:11 -1000 Subject: [PATCH 16/51] [api] Fix truncation of Home Assistant attributes longer than 255 characters (#13348) --- esphome/components/api/api_connection.cpp | 19 ++++++------ esphome/components/i2c/i2c.cpp | 8 ++--- esphome/components/i2c/i2c_bus.h | 24 +++------------ esphome/core/helpers.h | 29 +++++++++++++++++++ .../fixtures/api_homeassistant.yaml | 19 ++++++++++++ tests/integration/test_api_homeassistant.py | 27 +++++++++++++++-- 6 files changed, 90 insertions(+), 36 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0804985cc590..25512de4c7c5 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1712,17 +1712,16 @@ void APIConnection::on_home_assistant_state_response(const HomeAssistantStateRes } // Create null-terminated state for callback (parse_number needs null-termination) - // HA state max length is 255, so 256 byte buffer covers all cases - char state_buf[256]; - size_t copy_len = msg.state.size(); - if (copy_len >= sizeof(state_buf)) { - copy_len = sizeof(state_buf) - 1; // Truncate to leave space for null terminator + // 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); + char *state_buf = reinterpret_cast(state_buf_alloc.get()); + if (state_len > 0) { + memcpy(state_buf, msg.state.c_str(), state_len); } - if (copy_len > 0) { - memcpy(state_buf, msg.state.c_str(), copy_len); - } - state_buf[copy_len] = '\0'; - it.callback(StringRef(state_buf, copy_len)); + state_buf[state_len] = '\0'; + it.callback(StringRef(state_buf, state_len)); } } #endif diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index f8c7a1b40bd7..c1e7336ce403 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -42,8 +42,8 @@ ErrorCode I2CDevice::read_register16(uint16_t a_register, uint8_t *data, size_t } ErrorCode I2CDevice::write_register(uint8_t a_register, const uint8_t *data, size_t len) const { - SmallBufferWithHeapFallback<17> buffer_alloc; // Most I2C writes are <= 16 bytes - uint8_t *buffer = buffer_alloc.get(len + 1); + SmallBufferWithHeapFallback<17> buffer_alloc(len + 1); // Most I2C writes are <= 16 bytes + uint8_t *buffer = buffer_alloc.get(); buffer[0] = a_register; std::copy(data, data + len, buffer + 1); @@ -51,8 +51,8 @@ ErrorCode I2CDevice::write_register(uint8_t a_register, const uint8_t *data, siz } ErrorCode I2CDevice::write_register16(uint16_t a_register, const uint8_t *data, size_t len) const { - SmallBufferWithHeapFallback<18> buffer_alloc; // Most I2C writes are <= 16 bytes + 2 for register - uint8_t *buffer = buffer_alloc.get(len + 2); + SmallBufferWithHeapFallback<18> buffer_alloc(len + 2); // Most I2C writes are <= 16 bytes + 2 for register + uint8_t *buffer = buffer_alloc.get(); buffer[0] = a_register >> 8; buffer[1] = a_register; diff --git a/esphome/components/i2c/i2c_bus.h b/esphome/components/i2c/i2c_bus.h index 1acbe506a38b..3de5d5ca7b7f 100644 --- a/esphome/components/i2c/i2c_bus.h +++ b/esphome/components/i2c/i2c_bus.h @@ -11,22 +11,6 @@ namespace esphome { namespace i2c { -/// @brief Helper class for efficient buffer allocation - uses stack for small sizes, heap for large -template class SmallBufferWithHeapFallback { - public: - uint8_t *get(size_t size) { - if (size <= STACK_SIZE) { - return this->stack_buffer_; - } - this->heap_buffer_ = std::unique_ptr(new uint8_t[size]); - return this->heap_buffer_.get(); - } - - private: - uint8_t stack_buffer_[STACK_SIZE]; - std::unique_ptr heap_buffer_; -}; - /// @brief Error codes returned by I2CBus and I2CDevice methods enum ErrorCode { NO_ERROR = 0, ///< No error found during execution of method @@ -92,8 +76,8 @@ class I2CBus { total_len += read_buffers[i].len; } - SmallBufferWithHeapFallback<128> buffer_alloc; // Most I2C reads are small - uint8_t *buffer = buffer_alloc.get(total_len); + SmallBufferWithHeapFallback<128> buffer_alloc(total_len); // Most I2C reads are small + uint8_t *buffer = buffer_alloc.get(); auto err = this->write_readv(address, nullptr, 0, buffer, total_len); if (err != ERROR_OK) @@ -116,8 +100,8 @@ class I2CBus { total_len += write_buffers[i].len; } - SmallBufferWithHeapFallback<128> buffer_alloc; // Most I2C writes are small - uint8_t *buffer = buffer_alloc.get(total_len); + SmallBufferWithHeapFallback<128> buffer_alloc(total_len); // Most I2C writes are small + uint8_t *buffer = buffer_alloc.get(); size_t pos = 0; for (size_t i = 0; i != count; i++) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 8b581eb97a33..4185596c7bc6 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -366,6 +366,35 @@ template class FixedVector { const T *end() const { return data_ + size_; } }; +/// @brief Helper class for efficient buffer allocation - uses stack for small sizes, heap for large +/// This is useful when most operations need a small buffer but occasionally need larger ones. +/// The stack buffer avoids heap allocation in the common case, while heap fallback handles edge cases. +template class SmallBufferWithHeapFallback { + public: + explicit SmallBufferWithHeapFallback(size_t size) { + if (size <= STACK_SIZE) { + this->buffer_ = this->stack_buffer_; + } else { + this->heap_buffer_ = new uint8_t[size]; + this->buffer_ = this->heap_buffer_; + } + } + ~SmallBufferWithHeapFallback() { delete[] this->heap_buffer_; } + + // Delete copy and move operations to prevent double-delete + SmallBufferWithHeapFallback(const SmallBufferWithHeapFallback &) = delete; + SmallBufferWithHeapFallback &operator=(const SmallBufferWithHeapFallback &) = delete; + SmallBufferWithHeapFallback(SmallBufferWithHeapFallback &&) = delete; + SmallBufferWithHeapFallback &operator=(SmallBufferWithHeapFallback &&) = delete; + + uint8_t *get() { return this->buffer_; } + + private: + uint8_t stack_buffer_[STACK_SIZE]; + uint8_t *heap_buffer_{nullptr}; + uint8_t *buffer_; +}; + ///@} /// @name Mathematics diff --git a/tests/integration/fixtures/api_homeassistant.yaml b/tests/integration/fixtures/api_homeassistant.yaml index 8fe23b9a1933..2d77821ff33a 100644 --- a/tests/integration/fixtures/api_homeassistant.yaml +++ b/tests/integration/fixtures/api_homeassistant.yaml @@ -108,6 +108,25 @@ text_sensor: format: "HA Empty state updated: %s" args: ['x.c_str()'] + # Test long attribute handling (>255 characters) + # HA states are limited to 255 chars, but attributes are not + - platform: homeassistant + name: "HA Long Attribute" + entity_id: sensor.long_data + attribute: long_value + id: ha_long_attribute + on_value: + then: + - logger.log: + format: "HA Long attribute received, length: %d" + args: ['x.size()'] + # Log the first 50 and last 50 chars to verify no truncation + - lambda: |- + if (x.size() >= 100) { + ESP_LOGI("test", "Long attribute first 50 chars: %.50s", x.c_str()); + ESP_LOGI("test", "Long attribute last 50 chars: %s", x.c_str() + x.size() - 50); + } + # Number component for testing HA number control number: - platform: template diff --git a/tests/integration/test_api_homeassistant.py b/tests/integration/test_api_homeassistant.py index 3fe0dfe04535..b4adedf87382 100644 --- a/tests/integration/test_api_homeassistant.py +++ b/tests/integration/test_api_homeassistant.py @@ -40,6 +40,7 @@ async def test_api_homeassistant( humidity_update_future = loop.create_future() motion_update_future = loop.create_future() weather_update_future = loop.create_future() + long_attr_future = loop.create_future() # Number future ha_number_future = loop.create_future() @@ -58,6 +59,7 @@ async def test_api_homeassistant( humidity_update_pattern = re.compile(r"HA Humidity state updated: ([\d.]+)") motion_update_pattern = re.compile(r"HA Motion state changed: (ON|OFF)") weather_update_pattern = re.compile(r"HA Weather condition updated: (\w+)") + long_attr_pattern = re.compile(r"HA Long attribute received, length: (\d+)") # Number pattern ha_number_pattern = re.compile(r"Setting HA number to: ([\d.]+)") @@ -143,8 +145,14 @@ def check_output(line: str) -> None: elif not weather_update_future.done() and weather_update_pattern.search(line): weather_update_future.set_result(line) - # Check number pattern - elif not ha_number_future.done() and ha_number_pattern.search(line): + # Check long attribute pattern - separate if since it can come at different times + if not long_attr_future.done(): + match = long_attr_pattern.search(line) + if match: + long_attr_future.set_result(int(match.group(1))) + + # Check number pattern - separate if since it can come at different times + if not ha_number_future.done(): match = ha_number_pattern.search(line) if match: ha_number_future.set_result(match.group(1)) @@ -179,6 +187,14 @@ def check_output(line: str) -> None: client.send_home_assistant_state("binary_sensor.external_motion", "", "ON") client.send_home_assistant_state("weather.home", "condition", "sunny") + # Send a long attribute (300 characters) to test that attributes aren't truncated + # HA states are limited to 255 chars, but attributes are NOT limited + # This tests the fix for the 256-byte buffer truncation bug + long_attr_value = "X" * 300 # 300 chars - enough to expose truncation bug + client.send_home_assistant_state( + "sensor.long_data", "long_value", long_attr_value + ) + # Test edge cases for zero-copy implementation safety # Empty entity_id should be silently ignored (no crash) client.send_home_assistant_state("", "", "should_be_ignored") @@ -225,6 +241,13 @@ def check_output(line: str) -> None: number_value = await asyncio.wait_for(ha_number_future, timeout=5.0) assert number_value == "42.5", f"Unexpected number value: {number_value}" + # Long attribute test - verify 300 chars weren't truncated to 255 + long_attr_len = await asyncio.wait_for(long_attr_future, timeout=5.0) + assert long_attr_len == 300, ( + f"Long attribute was truncated! Expected 300 chars, got {long_attr_len}. " + "This indicates the 256-byte truncation bug." + ) + # Wait for completion await asyncio.wait_for(tests_complete_future, timeout=5.0) From 1a55254258e93f479b8ccd76cae2bb1264cb50d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:28:24 -1000 Subject: [PATCH 17/51] [status] Convert to PollingComponent to reduce CPU usage (#13342) --- esphome/components/status/binary_sensor.py | 4 ++-- esphome/components/status/status_binary_sensor.cpp | 8 +++----- esphome/components/status/status_binary_sensor.h | 10 ++++------ 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/esphome/components/status/binary_sensor.py b/esphome/components/status/binary_sensor.py index c1a4a52ce2be..f0c7c87e17e5 100644 --- a/esphome/components/status/binary_sensor.py +++ b/esphome/components/status/binary_sensor.py @@ -7,14 +7,14 @@ status_ns = cg.esphome_ns.namespace("status") StatusBinarySensor = status_ns.class_( - "StatusBinarySensor", binary_sensor.BinarySensor, cg.Component + "StatusBinarySensor", binary_sensor.BinarySensor, cg.PollingComponent ) CONFIG_SCHEMA = binary_sensor.binary_sensor_schema( StatusBinarySensor, device_class=DEVICE_CLASS_CONNECTIVITY, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, -).extend(cv.COMPONENT_SCHEMA) +).extend(cv.polling_component_schema("1s")) async def to_code(config): diff --git a/esphome/components/status/status_binary_sensor.cpp b/esphome/components/status/status_binary_sensor.cpp index 1795a9c41b77..2c95be856909 100644 --- a/esphome/components/status/status_binary_sensor.cpp +++ b/esphome/components/status/status_binary_sensor.cpp @@ -10,12 +10,11 @@ #include "esphome/components/api/api_server.h" #endif -namespace esphome { -namespace status { +namespace esphome::status { static const char *const TAG = "status"; -void StatusBinarySensor::loop() { +void StatusBinarySensor::update() { bool status = network::is_connected(); #ifdef USE_MQTT if (mqtt::global_mqtt_client != nullptr) { @@ -33,5 +32,4 @@ void StatusBinarySensor::loop() { void StatusBinarySensor::setup() { this->publish_initial_state(false); } void StatusBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Status Binary Sensor", this); } -} // namespace status -} // namespace esphome +} // namespace esphome::status diff --git a/esphome/components/status/status_binary_sensor.h b/esphome/components/status/status_binary_sensor.h index feda8b6328d0..7e8c31d7415d 100644 --- a/esphome/components/status/status_binary_sensor.h +++ b/esphome/components/status/status_binary_sensor.h @@ -3,12 +3,11 @@ #include "esphome/core/component.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace status { +namespace esphome::status { -class StatusBinarySensor : public binary_sensor::BinarySensor, public Component { +class StatusBinarySensor : public binary_sensor::BinarySensor, public PollingComponent { public: - void loop() override; + void update() override; void setup() override; void dump_config() override; @@ -16,5 +15,4 @@ class StatusBinarySensor : public binary_sensor::BinarySensor, public Component bool is_status_binary_sensor() const override { return true; } }; -} // namespace status -} // namespace esphome +} // namespace esphome::status From b44727aee67b8896dddf89ece727171aee5578ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:29:31 -1000 Subject: [PATCH 18/51] [socket] Eliminate heap allocations in set_sockaddr() (#13228) --- esphome/components/socket/socket.cpp | 10 +++++----- esphome/components/socket/socket.h | 12 +++++++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index c92e33393b28..bb94ecb67563 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -107,9 +107,9 @@ std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { #endif /* USE_NETWORK_IPV6 */ } -socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::string &ip_address, uint16_t port) { +socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_address, uint16_t port) { #if USE_NETWORK_IPV6 - if (ip_address.find(':') != std::string::npos) { + if (strchr(ip_address, ':') != nullptr) { if (addrlen < sizeof(sockaddr_in6)) { errno = EINVAL; return 0; @@ -121,14 +121,14 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::stri #ifdef USE_SOCKET_IMPL_BSD_SOCKETS // Use standard inet_pton for BSD sockets - if (inet_pton(AF_INET6, ip_address.c_str(), &server->sin6_addr) != 1) { + if (inet_pton(AF_INET6, ip_address, &server->sin6_addr) != 1) { errno = EINVAL; return 0; } #else // Use LWIP-specific functions ip6_addr_t ip6; - inet6_aton(ip_address.c_str(), &ip6); + inet6_aton(ip_address, &ip6); memcpy(server->sin6_addr.un.u32_addr, ip6.addr, sizeof(ip6.addr)); #endif return sizeof(sockaddr_in6); @@ -141,7 +141,7 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::stri auto *server = reinterpret_cast(addr); memset(server, 0, sizeof(sockaddr_in)); server->sin_family = AF_INET; - server->sin_addr.s_addr = inet_addr(ip_address.c_str()); + server->sin_addr.s_addr = inet_addr(ip_address); server->sin_port = htons(port); return sizeof(sockaddr_in); } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 9f9f61de85eb..d74804fdb003 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -87,7 +87,17 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol std::unique_ptr socket_ip_loop_monitored(int type, int protocol); /// Set a sockaddr to the specified address and port for the IP version used by socket_ip(). -socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::string &ip_address, uint16_t port); +/// @param addr Destination sockaddr structure +/// @param addrlen Size of the addr buffer +/// @param ip_address Null-terminated IP address string (IPv4 or IPv6) +/// @param port Port number in host byte order +/// @return Size of the sockaddr structure used, or 0 on error +socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_address, uint16_t port); + +/// Convenience overload for std::string (backward compatible). +inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::string &ip_address, uint16_t port) { + return set_sockaddr(addr, addrlen, ip_address.c_str(), port); +} /// Set a sockaddr to the any address and specified port for the IP version used by socket_ip(). socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port); From 2f7270cf8f6f069191920f81503466361e42515b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:36:32 -1000 Subject: [PATCH 19/51] [uart] Replace unsafe sprintf with buf_append_printf in debugger (#13288) --- esphome/components/uart/uart_debugger.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/uart/uart_debugger.cpp b/esphome/components/uart/uart_debugger.cpp index b51a57d68ee4..5490154d0107 100644 --- a/esphome/components/uart/uart_debugger.cpp +++ b/esphome/components/uart/uart_debugger.cpp @@ -107,7 +107,7 @@ void UARTDebug::log_hex(UARTDirection direction, std::vector bytes, uin if (i > 0) { res += separator; } - sprintf(buf, "%02X", bytes[i]); + buf_append_printf(buf, sizeof(buf), 0, "%02X", bytes[i]); res += buf; } ESP_LOGD(TAG, "%s", res.c_str()); @@ -147,7 +147,7 @@ void UARTDebug::log_string(UARTDirection direction, std::vector bytes) } else if (bytes[i] == 92) { res += "\\\\"; } else if (bytes[i] < 32 || bytes[i] > 127) { - sprintf(buf, "\\x%02X", bytes[i]); + buf_append_printf(buf, sizeof(buf), 0, "\\x%02X", bytes[i]); res += buf; } else { res += bytes[i]; @@ -166,11 +166,13 @@ void UARTDebug::log_int(UARTDirection direction, std::vector bytes, uin } else { res += ">>> "; } + char buf[4]; // max 3 digits for uint8_t (255) + null for (size_t i = 0; i < len; i++) { if (i > 0) { res += separator; } - res += to_string(bytes[i]); + buf_append_printf(buf, sizeof(buf), 0, "%u", bytes[i]); + res += buf; } ESP_LOGD(TAG, "%s", res.c_str()); delay(10); @@ -189,7 +191,7 @@ void UARTDebug::log_binary(UARTDirection direction, std::vector bytes, if (i > 0) { res += separator; } - sprintf(buf, "0b" BYTE_TO_BINARY_PATTERN " (0x%02X)", BYTE_TO_BINARY(bytes[i]), bytes[i]); + buf_append_printf(buf, sizeof(buf), 0, "0b" BYTE_TO_BINARY_PATTERN " (0x%02X)", BYTE_TO_BINARY(bytes[i]), bytes[i]); res += buf; } ESP_LOGD(TAG, "%s", res.c_str()); From 7b0db659d18722c827919bab0049f036cbae6ca1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:36:46 -1000 Subject: [PATCH 20/51] [rc522_spi] Replace unsafe sprintf with buf_append_printf (#13291) --- esphome/components/rc522_spi/rc522_spi.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/rc522_spi/rc522_spi.cpp b/esphome/components/rc522_spi/rc522_spi.cpp index 23e92be65a39..40da4498148a 100644 --- a/esphome/components/rc522_spi/rc522_spi.cpp +++ b/esphome/components/rc522_spi/rc522_spi.cpp @@ -1,4 +1,5 @@ #include "rc522_spi.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" // Based on: @@ -70,7 +71,7 @@ void RC522Spi::pcd_read_register(PcdRegister reg, ///< The register to read fro index++; #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - sprintf(cstrb, " %x", values[0]); + buf_append_printf(cstrb, sizeof(cstrb), 0, " %x", values[0]); buf.append(cstrb); #endif } @@ -78,7 +79,7 @@ void RC522Spi::pcd_read_register(PcdRegister reg, ///< The register to read fro values[index] = transfer_byte(address); // Read value and tell that we want to read the same address again. #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - sprintf(cstrb, " %x", values[index]); + buf_append_printf(cstrb, sizeof(cstrb), 0, " %x", values[index]); buf.append(cstrb); #endif @@ -88,7 +89,7 @@ void RC522Spi::pcd_read_register(PcdRegister reg, ///< The register to read fro #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE buf = buf + " "; - sprintf(cstrb, "%x", values[index]); + buf_append_printf(cstrb, sizeof(cstrb), 0, "%x", values[index]); buf.append(cstrb); ESP_LOGVV(TAG, "read_register_array_(%x, %d, , %d) -> %s", reg, count, rx_align, buf.c_str()); @@ -127,7 +128,7 @@ void RC522Spi::pcd_write_register(PcdRegister reg, ///< The register to write t transfer_byte(values[index]); #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - sprintf(cstrb, " %x", values[index]); + buf_append_printf(cstrb, sizeof(cstrb), 0, " %x", values[index]); buf.append(cstrb); #endif } From 052b05df56947a262724b63ab332cf97fae8e3db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:37:02 -1000 Subject: [PATCH 21/51] [tuya] Replace unsafe sprintf with snprintf in light color formatting (#13292) --- esphome/components/tuya/light/tuya_light.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/tuya/light/tuya_light.cpp b/esphome/components/tuya/light/tuya_light.cpp index c487f9f50bfe..097b3c1af82f 100644 --- a/esphome/components/tuya/light/tuya_light.cpp +++ b/esphome/components/tuya/light/tuya_light.cpp @@ -191,7 +191,7 @@ void TuyaLight::write_state(light::LightState *state) { case TuyaColorType::RGB: { char buffer[7]; const char *format_str = this->color_type_lowercase_ ? "%02x%02x%02x" : "%02X%02X%02X"; - sprintf(buffer, format_str, int(red * 255), int(green * 255), int(blue * 255)); + snprintf(buffer, sizeof(buffer), format_str, int(red * 255), int(green * 255), int(blue * 255)); color_value = buffer; break; } @@ -201,7 +201,7 @@ void TuyaLight::write_state(light::LightState *state) { rgb_to_hsv(red, green, blue, hue, saturation, value); char buffer[13]; const char *format_str = this->color_type_lowercase_ ? "%04x%04x%04x" : "%04X%04X%04X"; - sprintf(buffer, format_str, hue, int(saturation * 1000), int(value * 1000)); + snprintf(buffer, sizeof(buffer), format_str, hue, int(saturation * 1000), int(value * 1000)); color_value = buffer; break; } @@ -211,8 +211,8 @@ void TuyaLight::write_state(light::LightState *state) { rgb_to_hsv(red, green, blue, hue, saturation, value); char buffer[15]; const char *format_str = this->color_type_lowercase_ ? "%02x%02x%02x%04x%02x%02x" : "%02X%02X%02X%04X%02X%02X"; - sprintf(buffer, format_str, int(red * 255), int(green * 255), int(blue * 255), hue, int(saturation * 255), - int(value * 255)); + snprintf(buffer, sizeof(buffer), format_str, int(red * 255), int(green * 255), int(blue * 255), hue, + int(saturation * 255), int(value * 255)); color_value = buffer; break; } From 5b92d0b89e1e6a6b4cb87c8bdeaeb5a1adb4bdc8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:37:14 -1000 Subject: [PATCH 22/51] [wiegand] Replace heap-allocating to_string with stack buffers (#13294) --- esphome/components/wiegand/wiegand.cpp | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/esphome/components/wiegand/wiegand.cpp b/esphome/components/wiegand/wiegand.cpp index dd1443d10c09..f3f578794a65 100644 --- a/esphome/components/wiegand/wiegand.cpp +++ b/esphome/components/wiegand/wiegand.cpp @@ -1,4 +1,5 @@ #include "wiegand.h" +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -69,32 +70,35 @@ void Wiegand::loop() { for (auto *trigger : this->raw_triggers_) trigger->trigger(count, value); if (count == 26) { - std::string tag = to_string((value >> 1) & 0xffffff); - ESP_LOGD(TAG, "received 26-bit tag: %s", tag.c_str()); + char tag_buf[12]; // max 8 digits for 24-bit value + null + buf_append_printf(tag_buf, sizeof(tag_buf), 0, "%" PRIu32, static_cast((value >> 1) & 0xffffff)); + ESP_LOGD(TAG, "received 26-bit tag: %s", tag_buf); if (!check_eparity(value, 13, 13) || !check_oparity(value, 0, 13)) { ESP_LOGW(TAG, "invalid parity"); return; } for (auto *trigger : this->tag_triggers_) - trigger->trigger(tag); + trigger->trigger(tag_buf); } else if (count == 34) { - std::string tag = to_string((value >> 1) & 0xffffffff); - ESP_LOGD(TAG, "received 34-bit tag: %s", tag.c_str()); + char tag_buf[12]; // max 10 digits for 32-bit value + null + buf_append_printf(tag_buf, sizeof(tag_buf), 0, "%" PRIu32, static_cast((value >> 1) & 0xffffffff)); + ESP_LOGD(TAG, "received 34-bit tag: %s", tag_buf); if (!check_eparity(value, 17, 17) || !check_oparity(value, 0, 17)) { ESP_LOGW(TAG, "invalid parity"); return; } for (auto *trigger : this->tag_triggers_) - trigger->trigger(tag); + trigger->trigger(tag_buf); } else if (count == 37) { - std::string tag = to_string((value >> 1) & 0x7ffffffff); - ESP_LOGD(TAG, "received 37-bit tag: %s", tag.c_str()); + char tag_buf[12]; // max 11 digits for 35-bit value + null + buf_append_printf(tag_buf, sizeof(tag_buf), 0, "%" PRIu64, static_cast((value >> 1) & 0x7ffffffff)); + ESP_LOGD(TAG, "received 37-bit tag: %s", tag_buf); if (!check_eparity(value, 18, 19) || !check_oparity(value, 0, 19)) { ESP_LOGW(TAG, "invalid parity"); return; } for (auto *trigger : this->tag_triggers_) - trigger->trigger(tag); + trigger->trigger(tag_buf); } else if (count == 4) { for (auto *trigger : this->key_triggers_) trigger->trigger(value); From 0f3bac5dd65dcc62e9b3ef72aafbe8eafd4090f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:37:29 -1000 Subject: [PATCH 23/51] [nextion] Replace to_string with stack buffer and fix unsafe sprintf (#13295) --- esphome/components/nextion/nextion.cpp | 6 ++++-- esphome/components/nextion/nextion_upload_arduino.cpp | 2 +- esphome/components/nextion/nextion_upload_esp32.cpp | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index d77af510d790..354288e1a34c 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -1,6 +1,7 @@ #include "nextion.h" #include #include "esphome/core/application.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" @@ -1283,8 +1284,9 @@ void Nextion::check_pending_waveform_() { size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size() : 255; // ADDT command can only send 255 - std::string command = "addt " + to_string(component->get_component_id()) + "," + - to_string(component->get_wave_channel_id()) + "," + to_string(buffer_to_send); + char command[24]; // "addt " + uint8 + "," + uint8 + "," + uint8 + null = max 17 chars + buf_append_printf(command, sizeof(command), 0, "addt %u,%u,%zu", component->get_component_id(), + component->get_wave_channel_id(), buffer_to_send); if (!this->send_command_(command)) { delete nb; // NOLINT(cppcoreguidelines-owning-memory) this->waveform_queue_.pop_front(); diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index d210bad004f9..220c75f9d390 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -34,7 +34,7 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { } char range_header[32]; - sprintf(range_header, "bytes=%" PRIu32 "-%" PRIu32, range_start, range_end); + buf_append_printf(range_header, sizeof(range_header), 0, "bytes=%" PRIu32 "-%" PRIu32, range_start, range_end); ESP_LOGV(TAG, "Range: %s", range_header); http_client.addHeader("Range", range_header); int code = http_client.GET(); diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index 712fa8e78e56..c4e6ff718218 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -36,7 +36,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r } char range_header[32]; - sprintf(range_header, "bytes=%" PRIu32 "-%" PRIu32, range_start, range_end); + buf_append_printf(range_header, sizeof(range_header), 0, "bytes=%" PRIu32 "-%" PRIu32, range_start, range_end); ESP_LOGV(TAG, "Range: %s", range_header); esp_http_client_set_header(http_client, "Range", range_header); ESP_LOGV(TAG, "Open HTTP"); From eb664291444e32136d097e27b1ba889f1445e856 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:39:23 -1000 Subject: [PATCH 24/51] [sml] Use stack buffers instead of str_sprintf (#13222) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/sml/sml_parser.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/sml/sml_parser.cpp b/esphome/components/sml/sml_parser.cpp index 85e5a2da032a..16e37949dc7a 100644 --- a/esphome/components/sml/sml_parser.cpp +++ b/esphome/components/sml/sml_parser.cpp @@ -104,7 +104,10 @@ std::vector SmlFile::get_obis_info() { std::string bytes_repr(const BytesView &buffer) { std::string repr; for (auto const value : buffer) { - repr += str_sprintf("%02x", value & 0xff); + // max 3: 2 hex digits + null + char hex_buf[3]; + snprintf(hex_buf, sizeof(hex_buf), "%02x", static_cast(value)); + repr += hex_buf; } return repr; } @@ -146,7 +149,11 @@ ObisInfo::ObisInfo(const BytesView &server_id, const SmlNode &val_list_entry) : } std::string ObisInfo::code_repr() const { - return str_sprintf("%d-%d:%d.%d.%d", this->code[0], this->code[1], this->code[2], this->code[3], this->code[4]); + // max 20: "255-255:255.255.255" (19 chars) + null + char buf[20]; + snprintf(buf, sizeof(buf), "%d-%d:%d.%d.%d", this->code[0], this->code[1], this->code[2], this->code[3], + this->code[4]); + return buf; } } // namespace sml From f60c03e350482edf652eb91da62cbdbb6c03c5a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:39:53 -1000 Subject: [PATCH 25/51] [syslog] Use buf_append_printf for ESP8266 flash optimization (#13286) --- esphome/components/syslog/esphome_syslog.cpp | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index 83ad6b2720f0..376de54db474 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -47,29 +47,27 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t size_t remaining = sizeof(packet); // Write PRI - abort if this fails as packet would be malformed - int ret = snprintf(packet, remaining, "<%d>", pri); - if (ret <= 0 || static_cast(ret) >= remaining) { - return; + offset = buf_append_printf(packet, sizeof(packet), 0, "<%d>", pri); + if (offset == 0) { + return; // PRI always produces at least "<0>" (3 chars), so 0 means error } - offset = ret; - remaining -= ret; + remaining -= offset; // Write timestamp directly into packet (RFC 5424: use "-" if time not valid or strftime fails) auto now = this->time_->now(); size_t ts_written = now.is_valid() ? now.strftime(packet + offset, remaining, "%b %e %H:%M:%S") : 0; if (ts_written > 0) { offset += ts_written; - remaining -= ts_written; } else if (remaining > 0) { packet[offset++] = '-'; - remaining--; } // Write hostname, tag, and message - ret = snprintf(packet + offset, remaining, " %s %s: %.*s", App.get_name().c_str(), tag, (int) len, message); - if (ret > 0) { - // snprintf returns chars that would be written; clamp to actual buffer space - offset += std::min(static_cast(ret), remaining > 0 ? remaining - 1 : 0); + offset = buf_append_printf(packet, sizeof(packet), offset, " %s %s: %.*s", App.get_name().c_str(), tag, (int) len, + message); + // Clamp to exclude null terminator position if buffer was filled + if (offset >= sizeof(packet)) { + offset = sizeof(packet) - 1; } if (offset > 0) { From 67871a1683cac8b35c0e7d2a3ca885597e8cbbc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:40:14 -1000 Subject: [PATCH 26/51] [ccs811] Use buf_append_printf for buffer safety and ESP8266 flash optimization (#13300) --- esphome/components/ccs811/ccs811.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/ccs811/ccs811.cpp b/esphome/components/ccs811/ccs811.cpp index 84355f2793a1..9ff01b32b215 100644 --- a/esphome/components/ccs811/ccs811.cpp +++ b/esphome/components/ccs811/ccs811.cpp @@ -81,8 +81,8 @@ void CCS811Component::setup() { bootloader_version, application_version); if (this->version_ != nullptr) { char version[20]; // "15.15.15 (0xffff)" is 17 chars, plus NUL, plus wiggle room - sprintf(version, "%d.%d.%d (0x%02x)", (application_version >> 12 & 15), (application_version >> 8 & 15), - (application_version >> 4 & 15), application_version); + buf_append_printf(version, sizeof(version), 0, "%d.%d.%d (0x%02x)", (application_version >> 12 & 15), + (application_version >> 8 & 15), (application_version >> 4 & 15), application_version); ESP_LOGD(TAG, "publishing version state: %s", version); this->version_->publish_state(version); } From 226867b05caeaa9ba1644ca493830ec7d9126044 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:40:53 -1000 Subject: [PATCH 27/51] [esp8266] Use direct SDK calls instead of Arduino ESP class wrappers (#13353) --- esphome/components/esp8266/core.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 200ca567c223..784b87916b25 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -6,7 +6,11 @@ #include "esphome/core/helpers.h" #include "preferences.h" #include -#include +#include + +extern "C" { +#include +} namespace esphome { @@ -16,23 +20,19 @@ void IRAM_ATTR HOT delay(uint32_t ms) { ::delay(ms); } uint32_t IRAM_ATTR HOT micros() { return ::micros(); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { - ESP.restart(); // NOLINT(readability-static-accessed-through-instance) + system_restart(); // restart() doesn't always end execution while (true) { // NOLINT(clang-diagnostic-unreachable-code) yield(); } } void arch_init() {} -void IRAM_ATTR HOT arch_feed_wdt() { - ESP.wdtFeed(); // NOLINT(readability-static-accessed-through-instance) -} +void IRAM_ATTR HOT arch_feed_wdt() { system_soft_wdt_feed(); } uint8_t progmem_read_byte(const uint8_t *addr) { return pgm_read_byte(addr); // NOLINT } -uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { - return ESP.getCycleCount(); // NOLINT(readability-static-accessed-through-instance) -} +uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { return F_CPU; } void force_link_symbols() { From 6cbe67200494a7e98d8fd3f581c5c2535cfd5b4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:41:07 -1000 Subject: [PATCH 28/51] [tuya] Use buf_append_printf for ESP8266 flash optimization (#13287) --- esphome/components/tuya/text_sensor/tuya_text_sensor.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp b/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp index 36b6d630ae37..b15fb6f85a7e 100644 --- a/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp +++ b/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp @@ -24,7 +24,7 @@ void TuyaTextSensor::setup() { } case TuyaDatapointType::ENUM: { char buf[4]; // uint8_t max is 3 digits + null - snprintf(buf, sizeof(buf), "%u", datapoint.value_enum); + buf_append_printf(buf, sizeof(buf), 0, "%u", datapoint.value_enum); ESP_LOGD(TAG, "MCU reported text sensor %u is: %s", datapoint.id, buf); this->publish_state(buf); break; From 635983f163f74193849b7400a6ce4456245c4c0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:41:19 -1000 Subject: [PATCH 29/51] [uptime] Use buf_append_printf for ESP8266 flash optimization (#13282) --- .../components/uptime/text_sensor/uptime_text_sensor.cpp | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp index b7b3273f3986..acd3980a1ad9 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp @@ -9,17 +9,12 @@ namespace uptime { static const char *const TAG = "uptime.sensor"; -// Clamp position to valid buffer range when snprintf indicates truncation -static size_t clamp_buffer_pos(size_t pos, size_t buf_size) { return pos < buf_size ? pos : buf_size - 1; } - static void append_unit(char *buf, size_t buf_size, size_t &pos, const char *separator, unsigned value, const char *label) { if (pos > 0) { - pos += snprintf(buf + pos, buf_size - pos, "%s", separator); - pos = clamp_buffer_pos(pos, buf_size); + pos = buf_append_printf(buf, buf_size, pos, "%s", separator); } - pos += snprintf(buf + pos, buf_size - pos, "%u%s", value, label); - pos = clamp_buffer_pos(pos, buf_size); + pos = buf_append_printf(buf, buf_size, pos, "%u%s", value, label); } void UptimeTextSensor::setup() { From d8849b16f24a09611e7cd5bce686adf4fffa4b6f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:41:34 -1000 Subject: [PATCH 30/51] [gpio] Use buf_append_printf in dump_summary for ESP8266 flash optimization (#13283) --- esphome/components/ch422g/ch422g.cpp | 2 +- esphome/components/esp8266/gpio.cpp | 2 +- esphome/components/max6956/max6956.cpp | 2 +- esphome/components/mcp23016/mcp23016.cpp | 2 +- esphome/components/mcp23xxx_base/mcp23xxx_base.cpp | 2 +- esphome/components/mpr121/mpr121.cpp | 2 +- esphome/components/pca6416a/pca6416a.cpp | 2 +- esphome/components/pca9554/pca9554.cpp | 2 +- esphome/components/pcf8574/pcf8574.cpp | 2 +- esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp | 2 +- esphome/components/sn74hc165/sn74hc165.cpp | 2 +- esphome/components/sn74hc595/sn74hc595.cpp | 2 +- esphome/components/sx1509/sx1509_gpio_pin.cpp | 2 +- esphome/components/tca9555/tca9555.cpp | 2 +- esphome/components/weikai/weikai.cpp | 2 +- esphome/components/xl9535/xl9535.cpp | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index d031c31294f6..eef95b9ba28f 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -133,7 +133,7 @@ bool CH422GGPIOPin::digital_read() { return this->parent_->digital_read(this->pi void CH422GGPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value ^ this->inverted_); } size_t CH422GGPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "EXIO%u via CH422G", this->pin_); + return buf_append_printf(buffer, len, 0, "EXIO%u via CH422G", this->pin_); } void CH422GGPIOPin::set_flags(gpio::Flags flags) { flags_ = flags; diff --git a/esphome/components/esp8266/gpio.cpp b/esphome/components/esp8266/gpio.cpp index 7a5ee08984b8..659233443e26 100644 --- a/esphome/components/esp8266/gpio.cpp +++ b/esphome/components/esp8266/gpio.cpp @@ -99,7 +99,7 @@ void ESP8266GPIOPin::pin_mode(gpio::Flags flags) { } size_t ESP8266GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "GPIO%u", this->pin_); + return buf_append_printf(buffer, len, 0, "GPIO%u", this->pin_); } bool ESP8266GPIOPin::digital_read() { diff --git a/esphome/components/max6956/max6956.cpp b/esphome/components/max6956/max6956.cpp index 13fe5a532309..6ba17f11d111 100644 --- a/esphome/components/max6956/max6956.cpp +++ b/esphome/components/max6956/max6956.cpp @@ -162,7 +162,7 @@ void MAX6956GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this- bool MAX6956GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void MAX6956GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } size_t MAX6956GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "%u via Max6956", this->pin_); + return buf_append_printf(buffer, len, 0, "%u via Max6956", this->pin_); } } // namespace max6956 diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index 87c26689625e..56b2ecf9f4e7 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -100,7 +100,7 @@ void MCP23016GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this bool MCP23016GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void MCP23016GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } size_t MCP23016GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "%u via MCP23016", this->pin_); + return buf_append_printf(buffer, len, 0, "%u via MCP23016", this->pin_); } } // namespace mcp23016 diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp index 302f6b8280b5..535119fc5c15 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp @@ -17,7 +17,7 @@ template void MCP23XXXGPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } template size_t MCP23XXXGPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "%u via MCP23XXX", this->pin_); + return buf_append_printf(buffer, len, 0, "%u via MCP23XXX", this->pin_); } template class MCP23XXXGPIOPin<8>; diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index 4b358e384cc1..cd9c81fe034b 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -154,7 +154,7 @@ void MPR121GPIOPin::digital_write(bool value) { } size_t MPR121GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "ELE%u on MPR121", this->pin_); + return buf_append_printf(buffer, len, 0, "ELE%u on MPR121", this->pin_); } } // namespace mpr121 diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index 909bac5f0545..f393af88ceaf 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -181,7 +181,7 @@ void PCA6416AGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this bool PCA6416AGPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void PCA6416AGPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } size_t PCA6416AGPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "%u via PCA6416A", this->pin_); + return buf_append_printf(buffer, len, 0, "%u via PCA6416A", this->pin_); } } // namespace pca6416a diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index a6f9c2396c0c..c574ce6593ad 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -130,7 +130,7 @@ void PCA9554GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this- bool PCA9554GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void PCA9554GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } size_t PCA9554GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "%u via PCA9554", this->pin_); + return buf_append_printf(buffer, len, 0, "%u via PCA9554", this->pin_); } } // namespace pca9554 diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 8bdd312ab9bb..b7d3848f0ebb 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -107,7 +107,7 @@ void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this- bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void PCF8574GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } size_t PCF8574GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "%u via PCF8574", this->pin_); + return buf_append_printf(buffer, len, 0, "%u via PCF8574", this->pin_); } } // namespace pcf8574 diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index f3a1f013d978..fdff11dedb49 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -165,7 +165,7 @@ void PI4IOE5V6408GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } size_t PI4IOE5V6408GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "%u via PI4IOE5V6408", this->pin_); + return buf_append_printf(buffer, len, 0, "%u via PI4IOE5V6408", this->pin_); } } // namespace pi4ioe5v6408 diff --git a/esphome/components/sn74hc165/sn74hc165.cpp b/esphome/components/sn74hc165/sn74hc165.cpp index 718e0b86ed31..63b3f98521a1 100644 --- a/esphome/components/sn74hc165/sn74hc165.cpp +++ b/esphome/components/sn74hc165/sn74hc165.cpp @@ -65,7 +65,7 @@ float SN74HC165Component::get_setup_priority() const { return setup_priority::IO bool SN74HC165GPIOPin::digital_read() { return this->parent_->digital_read_(this->pin_) != this->inverted_; } size_t SN74HC165GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "%u via SN74HC165", this->pin_); + return buf_append_printf(buffer, len, 0, "%u via SN74HC165", this->pin_); } } // namespace sn74hc165 diff --git a/esphome/components/sn74hc595/sn74hc595.cpp b/esphome/components/sn74hc595/sn74hc595.cpp index 6b5c5d9fc4df..1bb8c7936dbf 100644 --- a/esphome/components/sn74hc595/sn74hc595.cpp +++ b/esphome/components/sn74hc595/sn74hc595.cpp @@ -94,7 +94,7 @@ void SN74HC595GPIOPin::digital_write(bool value) { this->parent_->digital_write_(this->pin_, value != this->inverted_); } size_t SN74HC595GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "%u via SN74HC595", this->pin_); + return buf_append_printf(buffer, len, 0, "%u via SN74HC595", this->pin_); } } // namespace sn74hc595 diff --git a/esphome/components/sx1509/sx1509_gpio_pin.cpp b/esphome/components/sx1509/sx1509_gpio_pin.cpp index 41a99eba4ba1..a7e5d0514d93 100644 --- a/esphome/components/sx1509/sx1509_gpio_pin.cpp +++ b/esphome/components/sx1509/sx1509_gpio_pin.cpp @@ -13,7 +13,7 @@ void SX1509GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this-> bool SX1509GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void SX1509GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } size_t SX1509GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "%u via sx1509", this->pin_); + return buf_append_printf(buffer, len, 0, "%u via sx1509", this->pin_); } } // namespace sx1509 diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 376de6a3708a..79c525389834 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -139,7 +139,7 @@ void TCA9555GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this- bool TCA9555GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void TCA9555GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } size_t TCA9555GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "%u via TCA9555", this->pin_); + return buf_append_printf(buffer, len, 0, "%u via TCA9555", this->pin_); } } // namespace tca9555 diff --git a/esphome/components/weikai/weikai.cpp b/esphome/components/weikai/weikai.cpp index 197b516bb29d..d0a8f8366b67 100644 --- a/esphome/components/weikai/weikai.cpp +++ b/esphome/components/weikai/weikai.cpp @@ -246,7 +246,7 @@ void WeikaiGPIOPin::setup() { } size_t WeikaiGPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "%u via WeiKai %s", this->pin_, this->parent_->get_name()); + return buf_append_printf(buffer, len, 0, "%u via WeiKai %s", this->pin_, this->parent_->get_name()); } /////////////////////////////////////////////////////////////////////////////// diff --git a/esphome/components/xl9535/xl9535.cpp b/esphome/components/xl9535/xl9535.cpp index dd6c8188ebd0..cfcbeeeb8d7d 100644 --- a/esphome/components/xl9535/xl9535.cpp +++ b/esphome/components/xl9535/xl9535.cpp @@ -111,7 +111,7 @@ void XL9535Component::pin_mode(uint8_t pin, gpio::Flags mode) { void XL9535GPIOPin::setup() { this->pin_mode(this->flags_); } size_t XL9535GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "%u via XL9535", this->pin_); + return buf_append_printf(buffer, len, 0, "%u via XL9535", this->pin_); } void XL9535GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } From 3182222d60e5d32ccdd3d1cefdc7e02d666038f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:41:47 -1000 Subject: [PATCH 31/51] [esp32_hosted] Use stack buffer instead of str_sprintf for version string (#13226) --- .../components/esp32_hosted/update/esp32_hosted_update.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index d69a438578a9..a82ee48718bd 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -69,7 +69,10 @@ void Esp32HostedUpdate::setup() { // Get coprocessor version esp_hosted_coprocessor_fwver_t ver_info; if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) { - this->update_info_.current_version = str_sprintf("%d.%d.%d", ver_info.major1, ver_info.minor1, ver_info.patch1); + // 16 bytes: "255.255.255" (11 chars) + null + safety margin + char buf[16]; + snprintf(buf, sizeof(buf), "%d.%d.%d", ver_info.major1, ver_info.minor1, ver_info.patch1); + this->update_info_.current_version = buf; } else { this->update_info_.current_version = "unknown"; } From ea0fac96cbfcea1866749f5e6d4dacaf04f4b472 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:42:04 -1000 Subject: [PATCH 32/51] [core][mqtt] Add str_sanitize_to(), soft-deprecate str_sanitize() (#13233) --- esphome/components/mqtt/mqtt_client.cpp | 3 ++- esphome/components/mqtt/mqtt_component.cpp | 5 +++-- esphome/core/helpers.cpp | 19 +++++++++++++++---- esphome/core/helpers.h | 18 ++++++++++++++++++ script/ci-custom.py | 2 ++ 5 files changed, 40 insertions(+), 7 deletions(-) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 0ab5b238b543..7d4050284f1a 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -635,7 +635,8 @@ void MQTTClientComponent::set_log_message_template(MQTTMessage &&message) { this const MQTTDiscoveryInfo &MQTTClientComponent::get_discovery_info() const { return this->discovery_info_; } void MQTTClientComponent::set_topic_prefix(const std::string &topic_prefix, const std::string &check_topic_prefix) { if (App.is_name_add_mac_suffix_enabled() && (topic_prefix == check_topic_prefix)) { - this->topic_prefix_ = str_sanitize(App.get_name()); + char buf[ESPHOME_DEVICE_NAME_MAX_LEN + 1]; + this->topic_prefix_ = str_sanitize_to(buf, App.get_name().c_str()); } else { this->topic_prefix_ = topic_prefix; } diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 8e4b3437ab60..3b9290259bef 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -48,7 +48,8 @@ void MQTTComponent::set_subscribe_qos(uint8_t qos) { this->subscribe_qos_ = qos; void MQTTComponent::set_retain(bool retain) { this->retain_ = retain; } std::string MQTTComponent::get_discovery_topic_(const MQTTDiscoveryInfo &discovery_info) const { - std::string sanitized_name = str_sanitize(App.get_name()); + char sanitized_name[ESPHOME_DEVICE_NAME_MAX_LEN + 1]; + str_sanitize_to(sanitized_name, App.get_name().c_str()); const char *comp_type = this->component_type(); char object_id_buf[OBJECT_ID_MAX_LEN]; StringRef object_id = this->get_default_object_id_to_(object_id_buf); @@ -60,7 +61,7 @@ std::string MQTTComponent::get_discovery_topic_(const MQTTDiscoveryInfo &discove p = append_char(p, '/'); p = append_str(p, comp_type, strlen(comp_type)); p = append_char(p, '/'); - p = append_str(p, sanitized_name.data(), sanitized_name.size()); + p = append_str(p, sanitized_name, strlen(sanitized_name)); p = append_char(p, '/'); p = append_str(p, object_id.c_str(), object_id.size()); p = append_str(p, "/config", 7); diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 44c20845e8a2..e7b901d71f3d 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -206,11 +206,22 @@ std::string str_snake_case(const std::string &str) { } return result; } -std::string str_sanitize(const std::string &str) { - std::string result = str; - for (char &c : result) { - c = to_sanitized_char(c); +char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { + if (buffer_size == 0) { + return buffer; + } + size_t i = 0; + while (*str && i < buffer_size - 1) { + buffer[i++] = to_sanitized_char(*str++); } + buffer[i] = '\0'; + return buffer; +} + +std::string str_sanitize(const std::string &str) { + std::string result; + result.resize(str.size()); + str_sanitize_to(&result[0], str.size() + 1, str.c_str()); return result; } std::string str_snprintf(const char *fmt, size_t len, ...) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 4185596c7bc6..bd3a4def059f 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -609,7 +609,25 @@ std::string str_snake_case(const std::string &str); constexpr char to_sanitized_char(char c) { return (c == '-' || c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) ? c : '_'; } + +/** Sanitize a string to buffer, keeping only alphanumerics, dashes, and underscores. + * + * @param buffer Output buffer to write to. + * @param buffer_size Size of the output buffer. + * @param str Input string to sanitize. + * @return Pointer to buffer. + * + * Buffer size needed: strlen(str) + 1. + */ +char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str); + +/// Sanitize a string to buffer. Automatically deduces buffer size. +template inline char *str_sanitize_to(char (&buffer)[N], const char *str) { + return str_sanitize_to(buffer, N, str); +} + /// Sanitizes the input string by removing all characters but alphanumerics, dashes and underscores. +/// @warning Allocates heap memory. Use str_sanitize_to() with a stack buffer instead. std::string str_sanitize(const std::string &str); /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. diff --git a/script/ci-custom.py b/script/ci-custom.py index e227ec873e9b..ee2e73872c33 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -688,6 +688,7 @@ def lint_trailing_whitespace(fname, match): "format_mac_address_pretty": "format_mac_addr_upper() with a stack buffer", "get_mac_address": "get_mac_address_into_buffer() with a stack buffer", "get_mac_address_pretty": "get_mac_address_pretty_into_buffer() with a stack buffer", + "str_sanitize": "str_sanitize_to() with a stack buffer", "str_truncate": "removal (function is unused)", "str_upper_case": "removal (function is unused)", "str_snake_case": "removal (function is unused)", @@ -706,6 +707,7 @@ def lint_trailing_whitespace(fname, match): r"format_mac_address_pretty|" r"get_mac_address_pretty(?!_)|" r"get_mac_address(?!_)|" + r"str_sanitize(?!_)|" r"str_truncate|" r"str_upper_case|" r"str_snake_case" From dfbf79d6d6af4c3ddd09f448f73c908fd102130f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:42:19 -1000 Subject: [PATCH 33/51] [homeassistant] Use buf_append_printf for ESP8266 flash optimization (#13284) --- .../components/homeassistant/number/homeassistant_number.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index 92ecd5ea3995..00ea88ff16b1 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -97,7 +97,7 @@ void HomeassistantNumber::control(float value) { entity_value.key = VALUE_KEY; // Stack buffer - no heap allocation; %g produces shortest representation char value_buf[16]; - snprintf(value_buf, sizeof(value_buf), "%g", value); + buf_append_printf(value_buf, sizeof(value_buf), 0, "%g", value); entity_value.value = StringRef(value_buf); api::global_api_server->send_homeassistant_action(resp); From d9fc625c6aab64837b412062d18f0fe86e9d09e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:43:05 -1000 Subject: [PATCH 34/51] [web_server] Simplify datetime formatting with buf_append_printf (#13281) --- esphome/components/web_server/web_server.cpp | 21 ++++---------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 61b9ea516337..e538a35e8cdd 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1188,11 +1188,7 @@ std::string WebServer::date_json_(datetime::DateEntity *obj, JsonDetail start_co // Format: YYYY-MM-DD (max 10 chars + null) char value[12]; -#ifdef USE_ESP8266 - snprintf_P(value, sizeof(value), PSTR("%d-%02d-%02d"), obj->year, obj->month, obj->day); -#else - snprintf(value, sizeof(value), "%d-%02d-%02d", obj->year, obj->month, obj->day); -#endif + buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d", obj->year, obj->month, obj->day); set_json_icon_state_value(root, obj, "date", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); @@ -1251,11 +1247,7 @@ std::string WebServer::time_json_(datetime::TimeEntity *obj, JsonDetail start_co // Format: HH:MM:SS (8 chars + null) char value[12]; -#ifdef USE_ESP8266 - snprintf_P(value, sizeof(value), PSTR("%02d:%02d:%02d"), obj->hour, obj->minute, obj->second); -#else - snprintf(value, sizeof(value), "%02d:%02d:%02d", obj->hour, obj->minute, obj->second); -#endif + buf_append_printf(value, sizeof(value), 0, "%02d:%02d:%02d", obj->hour, obj->minute, obj->second); set_json_icon_state_value(root, obj, "time", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); @@ -1314,13 +1306,8 @@ std::string WebServer::datetime_json_(datetime::DateTimeEntity *obj, JsonDetail // Format: YYYY-MM-DD HH:MM:SS (max 19 chars + null) char value[24]; -#ifdef USE_ESP8266 - snprintf_P(value, sizeof(value), PSTR("%d-%02d-%02d %02d:%02d:%02d"), obj->year, obj->month, obj->day, obj->hour, - obj->minute, obj->second); -#else - snprintf(value, sizeof(value), "%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, - obj->second); -#endif + buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, + obj->minute, obj->second); set_json_icon_state_value(root, obj, "datetime", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); From b9e72a877419302950c991dab158ec2e80e73c2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:43:19 -1000 Subject: [PATCH 35/51] [daikin_arc] Fix undefined behavior in sprintf calls (#13279) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/daikin_arc/daikin_arc.cpp | 21 +++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index f05342f4826d..472631080656 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -258,8 +258,9 @@ bool DaikinArcClimate::parse_state_frame_(const uint8_t frame[]) { } char buf[DAIKIN_STATE_FRAME_SIZE * 3 + 1] = {0}; + size_t pos = 0; for (size_t i = 0; i < DAIKIN_STATE_FRAME_SIZE; i++) { - sprintf(buf, "%s%02x ", buf, frame[i]); + pos = buf_append_printf(buf, sizeof(buf), pos, "%02x ", frame[i]); } ESP_LOGD(TAG, "FRAME %s", buf); @@ -349,8 +350,9 @@ bool DaikinArcClimate::on_receive(remote_base::RemoteReceiveData data) { if (data.expect_item(DAIKIN_HEADER_MARK, DAIKIN_HEADER_SPACE)) { valid_daikin_frame = true; size_t bytes_count = data.size() / 2 / 8; - std::unique_ptr buf(new char[bytes_count * 3 + 1]); - buf[0] = '\0'; + size_t buf_size = bytes_count * 3 + 1; + std::unique_ptr buf(new char[buf_size]()); // value-initialize (zero-fill) + size_t buf_pos = 0; for (size_t i = 0; i < bytes_count; i++) { uint8_t byte = 0; for (int8_t bit = 0; bit < 8; bit++) { @@ -361,19 +363,19 @@ bool DaikinArcClimate::on_receive(remote_base::RemoteReceiveData data) { break; } } - sprintf(buf.get(), "%s%02x ", buf.get(), byte); + buf_pos = buf_append_printf(buf.get(), buf_size, buf_pos, "%02x ", byte); } ESP_LOGD(TAG, "WHOLE FRAME %s size: %d", buf.get(), data.size()); } if (!valid_daikin_frame) { - char sbuf[16 * 10 + 1]; - sbuf[0] = '\0'; + char sbuf[16 * 10 + 1] = {0}; + size_t sbuf_pos = 0; for (size_t j = 0; j < static_cast(data.size()); j++) { if ((j - 2) % 16 == 0) { if (j > 0) { ESP_LOGD(TAG, "DATA %04x: %s", (j - 16 > 0xffff ? 0 : j - 16), sbuf); } - sbuf[0] = '\0'; + sbuf_pos = 0; } char type_ch = ' '; // debug_tolerance = 25% @@ -401,9 +403,10 @@ bool DaikinArcClimate::on_receive(remote_base::RemoteReceiveData data) { type_ch = '0'; if (abs(data[j]) > 100000) { - sprintf(sbuf, "%s%-5d[%c] ", sbuf, data[j] > 0 ? 99999 : -99999, type_ch); + sbuf_pos = buf_append_printf(sbuf, sizeof(sbuf), sbuf_pos, "%-5d[%c] ", data[j] > 0 ? 99999 : -99999, type_ch); } else { - sprintf(sbuf, "%s%-5d[%c] ", sbuf, (int) (round(data[j] / 10.) * 10), type_ch); + sbuf_pos = + buf_append_printf(sbuf, sizeof(sbuf), sbuf_pos, "%-5d[%c] ", (int) (round(data[j] / 10.) * 10), type_ch); } if (j + 1 == static_cast(data.size())) { ESP_LOGD(TAG, "DATA %04x: %s", (j - 8 > 0xffff ? 0 : j - 8), sbuf); From 98ccab87a7c26c2bb681597c12fe72f579c16019 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:43:36 -1000 Subject: [PATCH 36/51] [tormatic] Use stack buffers instead of str_sprintf in debug methods (#13225) --- .../components/tormatic/tormatic_protocol.h | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/esphome/components/tormatic/tormatic_protocol.h b/esphome/components/tormatic/tormatic_protocol.h index e26535e9855e..057713b88456 100644 --- a/esphome/components/tormatic/tormatic_protocol.h +++ b/esphome/components/tormatic/tormatic_protocol.h @@ -55,6 +55,7 @@ enum MessageType : uint16_t { COMMAND = 0x0106, }; +// Max string length: 7 ("Unknown"/"Command"). Update print() buffer sizes if adding longer strings. inline const char *message_type_to_str(MessageType t) { switch (t) { case STATUS: @@ -83,7 +84,11 @@ struct MessageHeader { } std::string print() { - return str_sprintf("MessageHeader: seq %d, len %d, type %s", this->seq, this->len, message_type_to_str(this->type)); + // 64 bytes: "MessageHeader: seq " + uint16 + ", len " + uint32 + ", type " + type + safety margin + char buf[64]; + buf_append_printf(buf, sizeof(buf), 0, "MessageHeader: seq %d, len %d, type %s", this->seq, this->len, + message_type_to_str(this->type)); + return buf; } void byteswap() { @@ -131,6 +136,7 @@ inline CoverOperation gate_status_to_cover_operation(GateStatus s) { return COVER_OPERATION_IDLE; } +// Max string length: 11 ("Ventilating"). Update print() buffer sizes if adding longer strings. inline const char *gate_status_to_str(GateStatus s) { switch (s) { case PAUSED: @@ -170,7 +176,12 @@ struct StatusReply { GateStatus state; uint8_t trailer = 0x0; - std::string print() { return str_sprintf("StatusReply: state %s", gate_status_to_str(this->state)); } + std::string print() { + // 48 bytes: "StatusReply: state " (19) + state (11) + safety margin + char buf[48]; + buf_append_printf(buf, sizeof(buf), 0, "StatusReply: state %s", gate_status_to_str(this->state)); + return buf; + } void byteswap(){}; } __attribute__((packed)); @@ -202,7 +213,12 @@ struct CommandRequestReply { CommandRequestReply() = default; CommandRequestReply(GateStatus state) { this->state = state; } - std::string print() { return str_sprintf("CommandRequestReply: state %s", gate_status_to_str(this->state)); } + std::string print() { + // 56 bytes: "CommandRequestReply: state " (27) + state (11) + safety margin + char buf[56]; + buf_append_printf(buf, sizeof(buf), 0, "CommandRequestReply: state %s", gate_status_to_str(this->state)); + return buf; + } void byteswap() { this->type = convert_big_endian(this->type); } } __attribute__((packed)); From 8142f5db44bfd7d54342ceb938d5d35bd2319359 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:43:50 -1000 Subject: [PATCH 37/51] [zephyr] Avoid heap allocation in preferences key formatting (#13215) --- esphome/components/zephyr/preferences.cpp | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/esphome/components/zephyr/preferences.cpp b/esphome/components/zephyr/preferences.cpp index 08b361b8fb44..311133a813bf 100644 --- a/esphome/components/zephyr/preferences.cpp +++ b/esphome/components/zephyr/preferences.cpp @@ -5,6 +5,8 @@ #include "esphome/core/preferences.h" #include "esphome/core/log.h" #include +#include +#include namespace esphome { namespace zephyr { @@ -13,6 +15,9 @@ static const char *const TAG = "zephyr.preferences"; #define ESPHOME_SETTINGS_KEY "esphome" +// Buffer size for key: "esphome/" (8) + max hex uint32 (8) + null terminator (1) = 17; use 20 for safety margin +static constexpr size_t KEY_BUFFER_SIZE = 20; + class ZephyrPreferenceBackend : public ESPPreferenceBackend { public: ZephyrPreferenceBackend(uint32_t type) { this->type_ = type; } @@ -27,7 +32,9 @@ class ZephyrPreferenceBackend : public ESPPreferenceBackend { bool load(uint8_t *data, size_t len) override { if (len != this->data.size()) { - ESP_LOGE(TAG, "size of setting key %s changed, from: %u, to: %u", get_key().c_str(), this->data.size(), len); + char key_buf[KEY_BUFFER_SIZE]; + this->format_key(key_buf, sizeof(key_buf)); + ESP_LOGE(TAG, "size of setting key %s changed, from: %u, to: %u", key_buf, this->data.size(), len); return false; } std::memcpy(data, this->data.data(), len); @@ -36,7 +43,7 @@ class ZephyrPreferenceBackend : public ESPPreferenceBackend { } uint32_t get_type() const { return this->type_; } - std::string get_key() const { return str_sprintf(ESPHOME_SETTINGS_KEY "/%" PRIx32, this->type_); } + void format_key(char *buf, size_t size) const { snprintf(buf, size, ESPHOME_SETTINGS_KEY "/%" PRIx32, this->type_); } std::vector data; @@ -85,7 +92,9 @@ class ZephyrPreferences : public ESPPreferences { } printf("type %u size %u\n", type, this->backends_.size()); auto *pref = new ZephyrPreferenceBackend(type); // NOLINT(cppcoreguidelines-owning-memory) - ESP_LOGD(TAG, "Add new setting %s.", pref->get_key().c_str()); + char key_buf[KEY_BUFFER_SIZE]; + pref->format_key(key_buf, sizeof(key_buf)); + ESP_LOGD(TAG, "Add new setting %s.", key_buf); this->backends_.push_back(pref); return ESPPreferenceObject(pref); } @@ -134,9 +143,10 @@ class ZephyrPreferences : public ESPPreferences { static int export_settings(int (*cb)(const char *name, const void *value, size_t val_len)) { for (auto *backend : static_cast(global_preferences)->backends_) { - auto name = backend->get_key(); - int err = cb(name.c_str(), backend->data.data(), backend->data.size()); - ESP_LOGD(TAG, "save in flash, name %s, len %u, err %d", name.c_str(), backend->data.size(), err); + char name[KEY_BUFFER_SIZE]; + backend->format_key(name, sizeof(name)); + int err = cb(name, backend->data.data(), backend->data.size()); + ESP_LOGD(TAG, "save in flash, name %s, len %u, err %d", name, backend->data.size(), err); } return 0; } From e40201a98db065a54dd508908244f8d6e4961b40 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:44:27 -1000 Subject: [PATCH 38/51] [cse7766] Use stack buffer for verbose debug logging (#13217) --- esphome/components/cse7766/cse7766.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index 71fe15f0ae06..4432195365d1 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -207,20 +207,24 @@ void CSE7766Component::parse_data_() { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE { - std::string buf = "Parsed:"; + // Buffer: 7 + 15 + 33 + 15 + 25 = 95 chars max + null, rounded to 128 for safety margin. + // Float sizes with %.4f can be up to 11 chars for large values (e.g., 999999.9999). + char buf[128]; + size_t pos = buf_append_printf(buf, sizeof(buf), 0, "Parsed:"); if (have_voltage) { - buf += str_sprintf(" V=%fV", voltage); + pos = buf_append_printf(buf, sizeof(buf), pos, " V=%.4fV", voltage); } if (have_current) { - buf += str_sprintf(" I=%fmA (~%fmA)", current * 1000.0f, calculated_current * 1000.0f); + pos = buf_append_printf(buf, sizeof(buf), pos, " I=%.4fmA (~%.4fmA)", current * 1000.0f, + calculated_current * 1000.0f); } if (have_power) { - buf += str_sprintf(" P=%fW", power); + pos = buf_append_printf(buf, sizeof(buf), pos, " P=%.4fW", power); } if (energy != 0.0f) { - buf += str_sprintf(" E=%fkWh (%u)", energy, cf_pulses); + buf_append_printf(buf, sizeof(buf), pos, " E=%.4fkWh (%u)", energy, cf_pulses); } - ESP_LOGVV(TAG, "%s", buf.c_str()); + ESP_LOGVV(TAG, "%s", buf); } #endif } From 126190d26a0570fe2f5030de85491980ee63506e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:44:41 -1000 Subject: [PATCH 39/51] [ezo] Replace str_sprintf with stack-based formatting (#13218) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/ezo/ezo.cpp | 28 +++++++++++++++++----------- esphome/components/ezo/ezo.h | 2 +- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/esphome/components/ezo/ezo.cpp b/esphome/components/ezo/ezo.cpp index 2e92c58e29b7..e4036021df77 100644 --- a/esphome/components/ezo/ezo.cpp +++ b/esphome/components/ezo/ezo.cpp @@ -160,7 +160,7 @@ void EZOSensor::loop() { this->commands_.pop_front(); } -void EZOSensor::add_command_(const std::string &command, EzoCommandType command_type, uint16_t delay_ms) { +void EZOSensor::add_command_(const char *command, EzoCommandType command_type, uint16_t delay_ms) { std::unique_ptr ezo_command(new EzoCommand); ezo_command->command = command; ezo_command->command_type = command_type; @@ -169,13 +169,17 @@ void EZOSensor::add_command_(const std::string &command, EzoCommandType command_ } void EZOSensor::set_calibration_point_(EzoCalibrationType type, float value) { - std::string payload = str_sprintf("Cal,%s,%0.2f", EZO_CALIBRATION_TYPE_STRINGS[type], value); + // max 21: "Cal,"(4) + type(4) + ","(1) + float(11) + null; use 24 for safety + char payload[24]; + snprintf(payload, sizeof(payload), "Cal,%s,%0.2f", EZO_CALIBRATION_TYPE_STRINGS[type], value); this->add_command_(payload, EzoCommandType::EZO_CALIBRATION, 900); } void EZOSensor::set_address(uint8_t address) { if (address > 0 && address < 128) { - std::string payload = str_sprintf("I2C,%u", address); + // max 8: "I2C,"(4) + uint8(3) + null + char payload[8]; + snprintf(payload, sizeof(payload), "I2C,%u", address); this->new_address_ = address; this->add_command_(payload, EzoCommandType::EZO_I2C); } else { @@ -194,7 +198,9 @@ void EZOSensor::get_slope() { this->add_command_("Slope,?", EzoCommandType::EZO_ void EZOSensor::get_t() { this->add_command_("T,?", EzoCommandType::EZO_T); } void EZOSensor::set_t(float value) { - std::string payload = str_sprintf("T,%0.2f", value); + // max 14 bytes: "T,"(2) + float with "%0.2f" (up to 11 chars) + null(1); use 16 for alignment + char payload[16]; + snprintf(payload, sizeof(payload), "T,%0.2f", value); this->add_command_(payload, EzoCommandType::EZO_T); } @@ -215,7 +221,9 @@ void EZOSensor::set_calibration_point_high(float value) { } void EZOSensor::set_calibration_generic(float value) { - std::string payload = str_sprintf("Cal,%0.2f", value); + // exact 16 bytes: "Cal," (4) + float with "%0.2f" (up to 11 chars, e.g. "-9999999.99") + null (1) = 16 + char payload[16]; + snprintf(payload, sizeof(payload), "Cal,%0.2f", value); this->add_command_(payload, EzoCommandType::EZO_CALIBRATION, 900); } @@ -223,13 +231,11 @@ void EZOSensor::clear_calibration() { this->add_command_("Cal,clear", EzoCommand void EZOSensor::get_led_state() { this->add_command_("L,?", EzoCommandType::EZO_LED); } -void EZOSensor::set_led_state(bool on) { - std::string to_send = "L,"; - to_send += on ? "1" : "0"; - this->add_command_(to_send, EzoCommandType::EZO_LED); -} +void EZOSensor::set_led_state(bool on) { this->add_command_(on ? "L,1" : "L,0", EzoCommandType::EZO_LED); } -void EZOSensor::send_custom(const std::string &to_send) { this->add_command_(to_send, EzoCommandType::EZO_CUSTOM); } +void EZOSensor::send_custom(const std::string &to_send) { + this->add_command_(to_send.c_str(), EzoCommandType::EZO_CUSTOM); +} } // namespace ezo } // namespace esphome diff --git a/esphome/components/ezo/ezo.h b/esphome/components/ezo/ezo.h index 00dd98fc80b1..f1a2802cbd72 100644 --- a/esphome/components/ezo/ezo.h +++ b/esphome/components/ezo/ezo.h @@ -92,7 +92,7 @@ class EZOSensor : public sensor::Sensor, public PollingComponent, public i2c::I2 std::deque> commands_; int new_address_; - void add_command_(const std::string &command, EzoCommandType command_type, uint16_t delay_ms = 300); + void add_command_(const char *command, EzoCommandType command_type, uint16_t delay_ms = 300); void set_calibration_point_(EzoCalibrationType type, float value); From f453a8d9a16adcee38cbc8c40bf13140bb50ae11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:44:56 -1000 Subject: [PATCH 40/51] [dfrobot_sen0395] Reduce heap allocations in command building (#13219) --- .../components/dfrobot_sen0395/commands.cpp | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/esphome/components/dfrobot_sen0395/commands.cpp b/esphome/components/dfrobot_sen0395/commands.cpp index 8bb6ddf942d8..2c44c6fba977 100644 --- a/esphome/components/dfrobot_sen0395/commands.cpp +++ b/esphome/components/dfrobot_sen0395/commands.cpp @@ -127,7 +127,9 @@ DetRangeCfgCommand::DetRangeCfgCommand(float min1, float max1, float min2, float this->min2_ = min2 = this->max2_ = max2 = this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 = this->max4_ = max4 = -1; - this->cmd_ = str_sprintf("detRangeCfg -1 %.0f %.0f", min1 / 0.15, max1 / 0.15); + char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f", min1 / 0.15, max1 / 0.15); + this->cmd_ = buf; } else if (min3 < 0 || max3 < 0) { this->min1_ = min1 = round(min1 / 0.15) * 0.15; this->max1_ = max1 = round(max1 / 0.15) * 0.15; @@ -135,7 +137,10 @@ DetRangeCfgCommand::DetRangeCfgCommand(float min1, float max1, float min2, float this->max2_ = max2 = round(max2 / 0.15) * 0.15; this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 = this->max4_ = max4 = -1; - this->cmd_ = str_sprintf("detRangeCfg -1 %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, min2 / 0.15, max2 / 0.15); + char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, min2 / 0.15, + max2 / 0.15); + this->cmd_ = buf; } else if (min4 < 0 || max4 < 0) { this->min1_ = min1 = round(min1 / 0.15) * 0.15; this->max1_ = max1 = round(max1 / 0.15) * 0.15; @@ -145,9 +150,10 @@ DetRangeCfgCommand::DetRangeCfgCommand(float min1, float max1, float min2, float this->max3_ = max3 = round(max3 / 0.15) * 0.15; this->min4_ = min4 = this->max4_ = max4 = -1; - this->cmd_ = str_sprintf("detRangeCfg -1 " - "%.0f %.0f %.0f %.0f %.0f %.0f", - min1 / 0.15, max1 / 0.15, min2 / 0.15, max2 / 0.15, min3 / 0.15, max3 / 0.15); + char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, min2 / 0.15, + max2 / 0.15, min3 / 0.15, max3 / 0.15); + this->cmd_ = buf; } else { this->min1_ = min1 = round(min1 / 0.15) * 0.15; this->max1_ = max1 = round(max1 / 0.15) * 0.15; @@ -158,10 +164,10 @@ DetRangeCfgCommand::DetRangeCfgCommand(float min1, float max1, float min2, float this->min4_ = min4 = round(min4 / 0.15) * 0.15; this->max4_ = max4 = round(max4 / 0.15) * 0.15; - this->cmd_ = str_sprintf("detRangeCfg -1 " - "%.0f %.0f %.0f %.0f %.0f %.0f %.0f %.0f", - min1 / 0.15, max1 / 0.15, min2 / 0.15, max2 / 0.15, min3 / 0.15, max3 / 0.15, min4 / 0.15, - max4 / 0.15); + char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, + min2 / 0.15, max2 / 0.15, min3 / 0.15, max3 / 0.15, min4 / 0.15, max4 / 0.15); + this->cmd_ = buf; } this->min1_ = min1; @@ -203,7 +209,10 @@ SetLatencyCommand::SetLatencyCommand(float delay_after_detection, float delay_af delay_after_disappear = std::round(delay_after_disappear / 0.025f) * 0.025f; this->delay_after_detection_ = clamp(delay_after_detection, 0.0f, 1638.375f); this->delay_after_disappear_ = clamp(delay_after_disappear, 0.0f, 1638.375f); - this->cmd_ = str_sprintf("setLatency %.03f %.03f", this->delay_after_detection_, this->delay_after_disappear_); + // max 32: "setLatency "(11) + float(8) + " "(1) + float(8) + null, rounded to 32 + char buf[32]; + snprintf(buf, sizeof(buf), "setLatency %.03f %.03f", this->delay_after_detection_, this->delay_after_disappear_); + this->cmd_ = buf; }; uint8_t SetLatencyCommand::on_message(std::string &message) { From e99dbe05f73a5735995366d55804198affc68fcd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:52:34 -1000 Subject: [PATCH 41/51] [toshiba] Replace to_string with stack buffer in debug logging (#13296) --- esphome/components/toshiba/toshiba.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 5efa70d6b418..7b5e78af5205 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -1,5 +1,6 @@ #include "toshiba.h" #include "esphome/components/remote_base/toshiba_ac_protocol.h" +#include "esphome/core/helpers.h" #include @@ -427,10 +428,17 @@ void ToshibaClimate::setup() { // Never send nan to HA if (std::isnan(this->target_temperature)) this->target_temperature = 24; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE // Log final state for debugging HA errors - ESP_LOGV(TAG, "Setup complete - Mode: %d, Fan: %s, Swing: %d, Temp: %.1f", static_cast(this->mode), - this->fan_mode.has_value() ? std::to_string(static_cast(this->fan_mode.value())).c_str() : "NONE", + const char *fan_mode_str = "NONE"; + char fan_mode_buf[4]; // max 3 digits for fan mode enum + null + if (this->fan_mode.has_value()) { + buf_append_printf(fan_mode_buf, sizeof(fan_mode_buf), 0, "%d", static_cast(this->fan_mode.value())); + fan_mode_str = fan_mode_buf; + } + ESP_LOGV(TAG, "Setup complete - Mode: %d, Fan: %s, Swing: %d, Temp: %.1f", static_cast(this->mode), fan_mode_str, static_cast(this->swing_mode), this->target_temperature); +#endif } void ToshibaClimate::transmit_state() { From e80a9402228907b01230d992f6248e6f590f6fa5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:52:49 -1000 Subject: [PATCH 42/51] [gdk101] Use stack buffer to eliminate heap allocation for firmware version (#13224) --- esphome/components/gdk101/gdk101.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index 617e2138fb1b..ddf38f2f55f1 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -163,9 +163,10 @@ bool GDK101Component::read_fw_version_(uint8_t *data) { return false; } - const std::string fw_version_str = str_sprintf("%d.%d", data[0], data[1]); - - this->fw_version_text_sensor_->publish_state(fw_version_str); + // max 8: "255.255" (7 chars) + null + char buf[8]; + snprintf(buf, sizeof(buf), "%d.%d", data[0], data[1]); + this->fw_version_text_sensor_->publish_state(buf); } #endif // USE_TEXT_SENSOR return true; From d8a28f6fba5c6dba85223da3386674992680ed1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 18:54:30 -1000 Subject: [PATCH 43/51] [scheduler] Replace resize() with erase() to save ~ 436 bytes flash (#13214) --- esphome/core/scheduler.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 8c2e349180fb..7de1023e6df4 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -403,7 +403,9 @@ class Scheduler { for (size_t i = 0; i < remaining; i++) { this->defer_queue_[i] = std::move(this->defer_queue_[this->defer_queue_front_ + i]); } - this->defer_queue_.resize(remaining); + // Use erase() instead of resize() to avoid instantiating _M_default_append + // (saves ~156 bytes flash). Erasing from the end is O(1) - no shifting needed. + this->defer_queue_.erase(this->defer_queue_.begin() + remaining, this->defer_queue_.end()); } this->defer_queue_front_ = 0; } From 86a1b4cf694026ea77f2c37d406b55e84203b122 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 19:51:11 -1000 Subject: [PATCH 44/51] [select][fan] Use StringRef for on_value/on_preset_set triggers to avoid heap allocation (#13324) --- esphome/codegen.py | 1 + esphome/components/fan/__init__.py | 4 +- esphome/components/fan/automation.h | 4 +- esphome/components/select/__init__.py | 4 +- esphome/components/select/automation.h | 4 +- esphome/core/string_ref.h | 64 ++++++++ esphome/cpp_types.py | 1 + .../fixtures/select_stringref_trigger.yaml | 85 +++++++++++ .../test_select_stringref_trigger.py | 143 ++++++++++++++++++ 9 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 tests/integration/fixtures/select_stringref_trigger.yaml create mode 100644 tests/integration/test_select_stringref_trigger.py diff --git a/esphome/codegen.py b/esphome/codegen.py index 6d55c6023d20..4a2a5975c67a 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -69,6 +69,7 @@ JsonObjectConst, Parented, PollingComponent, + StringRef, arduino_json_ns, bool_, const_char_ptr, diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 35a351e8f105..6010aa8ed46e 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -77,7 +77,7 @@ "FanSpeedSetTrigger", automation.Trigger.template(cg.int_) ) FanPresetSetTrigger = fan_ns.class_( - "FanPresetSetTrigger", automation.Trigger.template(cg.std_string) + "FanPresetSetTrigger", automation.Trigger.template(cg.StringRef) ) FanIsOnCondition = fan_ns.class_("FanIsOnCondition", automation.Condition.template()) @@ -287,7 +287,7 @@ async def setup_fan_core_(var, config): await automation.build_automation(trigger, [(cg.int_, "x")], conf) for conf in config.get(CONF_ON_PRESET_SET, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + await automation.build_automation(trigger, [(cg.StringRef, "x")], conf) async def register_fan(var, config): diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 77abc2f13ff9..3c3b0ce519e5 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -208,7 +208,7 @@ class FanSpeedSetTrigger : public Trigger { int last_speed_; }; -class FanPresetSetTrigger : public Trigger { +class FanPresetSetTrigger : public Trigger { public: FanPresetSetTrigger(Fan *state) { state->add_on_state_callback([this, state]() { @@ -216,7 +216,7 @@ class FanPresetSetTrigger : public Trigger { auto should_trigger = preset_mode != this->last_preset_mode_; this->last_preset_mode_ = preset_mode; if (should_trigger) { - this->trigger(std::string(preset_mode)); + this->trigger(preset_mode); } }); this->last_preset_mode_ = state->get_preset_mode(); diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index c51131a2922c..84ad591ba13f 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -33,7 +33,7 @@ # Triggers SelectStateTrigger = select_ns.class_( "SelectStateTrigger", - automation.Trigger.template(cg.std_string, cg.size_t), + automation.Trigger.template(cg.StringRef, cg.size_t), ) # Actions @@ -100,7 +100,7 @@ async def setup_select_core_(var, config, *, options: list[str]): for conf in config.get(CONF_ON_VALUE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation( - trigger, [(cg.std_string, "x"), (cg.size_t, "i")], conf + trigger, [(cg.StringRef, "x"), (cg.size_t, "i")], conf ) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index 81e8a3561dbc..ffdabd5f7c17 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -6,11 +6,11 @@ namespace esphome::select { -class SelectStateTrigger : public Trigger { +class SelectStateTrigger : public Trigger { public: explicit SelectStateTrigger(Select *parent) : parent_(parent) { parent->add_on_state_callback( - [this](size_t index) { this->trigger(std::string(this->parent_->option_at(index)), index); }); + [this](size_t index) { this->trigger(StringRef(this->parent_->option_at(index)), index); }); } protected: diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 44ca79c81b05..d502c4d27fa3 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -72,6 +72,7 @@ class StringRef { constexpr const char *c_str() const { return base_; } constexpr size_type size() const { return len_; } + constexpr size_type length() const { return len_; } constexpr bool empty() const { return len_ == 0; } constexpr const_reference operator[](size_type pos) const { return *(base_ + pos); } @@ -80,6 +81,32 @@ class StringRef { operator std::string() const { return str(); } + /// Find first occurrence of substring, returns std::string::npos if not found. + /// Note: Requires the underlying string to be null-terminated. + size_type find(const char *s, size_type pos = 0) const { + if (pos >= len_) + return std::string::npos; + const char *result = std::strstr(base_ + pos, s); + // Verify entire match is within bounds (strstr searches to null terminator) + if (result && result + std::strlen(s) <= base_ + len_) + return static_cast(result - base_); + return std::string::npos; + } + size_type find(char c, size_type pos = 0) const { + if (pos >= len_) + return std::string::npos; + const void *result = std::memchr(base_ + pos, static_cast(c), len_ - pos); + return result ? static_cast(static_cast(result) - base_) : std::string::npos; + } + + /// Return substring as std::string + std::string substr(size_type pos = 0, size_type count = std::string::npos) const { + if (pos >= len_) + return std::string(); + size_type actual_count = (count == std::string::npos || pos + count > len_) ? len_ - pos : count; + return std::string(base_ + pos, actual_count); + } + private: const char *base_; size_type len_; @@ -160,6 +187,43 @@ inline std::string operator+(const std::string &lhs, const StringRef &rhs) { str.append(rhs.c_str(), rhs.size()); return str; } +// String conversion functions for ADL compatibility (allows stoi(x) where x is StringRef) +// Must be in esphome namespace for ADL to find them. Uses strtol/strtod directly to avoid heap allocation. +namespace internal { +// NOLINTBEGIN(google-runtime-int) +template inline R parse_number(const StringRef &str, size_t *pos, F conv) { + char *end; + R result = conv(str.c_str(), &end); + // Set pos to 0 on conversion failure (when no characters consumed), otherwise index after number + if (pos) + *pos = (end == str.c_str()) ? 0 : static_cast(end - str.c_str()); + return result; +} +template inline R parse_number(const StringRef &str, size_t *pos, int base, F conv) { + char *end; + R result = conv(str.c_str(), &end, base); + // Set pos to 0 on conversion failure (when no characters consumed), otherwise index after number + if (pos) + *pos = (end == str.c_str()) ? 0 : static_cast(end - str.c_str()); + return result; +} +// NOLINTEND(google-runtime-int) +} // namespace internal +// NOLINTBEGIN(readability-identifier-naming,google-runtime-int) +inline int stoi(const StringRef &str, size_t *pos = nullptr, int base = 10) { + return static_cast(internal::parse_number(str, pos, base, std::strtol)); +} +inline long stol(const StringRef &str, size_t *pos = nullptr, int base = 10) { + return internal::parse_number(str, pos, base, std::strtol); +} +inline float stof(const StringRef &str, size_t *pos = nullptr) { + return internal::parse_number(str, pos, std::strtof); +} +inline double stod(const StringRef &str, size_t *pos = nullptr) { + return internal::parse_number(str, pos, std::strtod); +} +// NOLINTEND(readability-identifier-naming,google-runtime-int) + #ifdef USE_JSON // NOLINTNEXTLINE(readability-identifier-naming) inline void convertToJson(const StringRef &src, JsonVariant dst) { dst.set(src.c_str()); } diff --git a/esphome/cpp_types.py b/esphome/cpp_types.py index 0d1813f63b5b..7001c38857ae 100644 --- a/esphome/cpp_types.py +++ b/esphome/cpp_types.py @@ -44,3 +44,4 @@ EntityCategory = esphome_ns.enum("EntityCategory") Parented = esphome_ns.class_("Parented") ESPTime = esphome_ns.struct("ESPTime") +StringRef = esphome_ns.class_("StringRef") diff --git a/tests/integration/fixtures/select_stringref_trigger.yaml b/tests/integration/fixtures/select_stringref_trigger.yaml new file mode 100644 index 000000000000..bb1e1fd8433c --- /dev/null +++ b/tests/integration/fixtures/select_stringref_trigger.yaml @@ -0,0 +1,85 @@ +esphome: + name: select-stringref-test + friendly_name: Select StringRef Test + +host: + +logger: + level: DEBUG + +api: + +select: + - platform: template + name: "Test Select" + id: test_select + optimistic: true + options: + - "Option A" + - "Option B" + - "Option C" + initial_option: "Option A" + on_value: + then: + # Test 1: Log the value directly (StringRef -> const char* via c_str()) + - logger.log: + format: "Select value: %s" + args: ['x.c_str()'] + # Test 2: String concatenation (StringRef + const char* -> std::string) + - lambda: |- + std::string with_suffix = x + " selected"; + ESP_LOGI("test", "Concatenated: %s", with_suffix.c_str()); + # Test 3: Comparison (StringRef == const char*) + - lambda: |- + if (x == "Option B") { + ESP_LOGI("test", "Option B was selected"); + } + # Test 4: Use index parameter (variable name is 'i') + - lambda: |- + ESP_LOGI("test", "Select index: %d", (int)i); + # Test 5: StringRef.length() method + - lambda: |- + ESP_LOGI("test", "Length: %d", (int)x.length()); + # Test 6: StringRef.find() method with substring + - lambda: |- + if (x.find("Option") != std::string::npos) { + ESP_LOGI("test", "Found 'Option' in value"); + } + # Test 7: StringRef.find() method with character + - lambda: |- + size_t space_pos = x.find(' '); + if (space_pos != std::string::npos) { + ESP_LOGI("test", "Space at position: %d", (int)space_pos); + } + # Test 8: StringRef.substr() method + - lambda: |- + std::string prefix = x.substr(0, 6); + ESP_LOGI("test", "Substr prefix: %s", prefix.c_str()); + + # Second select with numeric options to test ADL functions + - platform: template + name: "Baud Rate" + id: baud_select + optimistic: true + options: + - "9600" + - "115200" + initial_option: "9600" + on_value: + then: + # Test 9: stoi via ADL + - lambda: |- + int baud = stoi(x); + ESP_LOGI("test", "stoi result: %d", baud); + # Test 10: stol via ADL + - lambda: |- + long baud_long = stol(x); + ESP_LOGI("test", "stol result: %ld", baud_long); + # Test 11: stof via ADL + - lambda: |- + float baud_float = stof(x); + ESP_LOGI("test", "stof result: %.0f", baud_float); + # Test 12: stod via ADL + - lambda: |- + double baud_double = stod(x); + ESP_LOGI("test", "stod result: %.0f", baud_double); diff --git a/tests/integration/test_select_stringref_trigger.py b/tests/integration/test_select_stringref_trigger.py new file mode 100644 index 000000000000..7fc72a229017 --- /dev/null +++ b/tests/integration/test_select_stringref_trigger.py @@ -0,0 +1,143 @@ +"""Integration test for select on_value trigger with StringRef parameter.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_select_stringref_trigger( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test select on_value trigger passes StringRef that works with string operations.""" + loop = asyncio.get_running_loop() + + # Track log messages to verify StringRef operations work + value_logged_future = loop.create_future() + concatenated_future = loop.create_future() + comparison_future = loop.create_future() + index_logged_future = loop.create_future() + length_future = loop.create_future() + find_substr_future = loop.create_future() + find_char_future = loop.create_future() + substr_future = loop.create_future() + # ADL functions + stoi_future = loop.create_future() + stol_future = loop.create_future() + stof_future = loop.create_future() + stod_future = loop.create_future() + + # Patterns to match in logs + value_pattern = re.compile(r"Select value: Option B") + concatenated_pattern = re.compile(r"Concatenated: Option B selected") + comparison_pattern = re.compile(r"Option B was selected") + index_pattern = re.compile(r"Select index: 1") + length_pattern = re.compile(r"Length: 8") # "Option B" is 8 chars + find_substr_pattern = re.compile(r"Found 'Option' in value") + find_char_pattern = re.compile(r"Space at position: 6") # space at index 6 + substr_pattern = re.compile(r"Substr prefix: Option") + # ADL function patterns (115200 from baud rate select) + stoi_pattern = re.compile(r"stoi result: 115200") + stol_pattern = re.compile(r"stol result: 115200") + stof_pattern = re.compile(r"stof result: 115200") + stod_pattern = re.compile(r"stod result: 115200") + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if not value_logged_future.done() and value_pattern.search(line): + value_logged_future.set_result(True) + if not concatenated_future.done() and concatenated_pattern.search(line): + concatenated_future.set_result(True) + if not comparison_future.done() and comparison_pattern.search(line): + comparison_future.set_result(True) + if not index_logged_future.done() and index_pattern.search(line): + index_logged_future.set_result(True) + if not length_future.done() and length_pattern.search(line): + length_future.set_result(True) + if not find_substr_future.done() and find_substr_pattern.search(line): + find_substr_future.set_result(True) + if not find_char_future.done() and find_char_pattern.search(line): + find_char_future.set_result(True) + if not substr_future.done() and substr_pattern.search(line): + substr_future.set_result(True) + # ADL functions + if not stoi_future.done() and stoi_pattern.search(line): + stoi_future.set_result(True) + if not stol_future.done() and stol_pattern.search(line): + stol_future.set_result(True) + if not stof_future.done() and stof_pattern.search(line): + stof_future.set_result(True) + if not stod_future.done() and stod_pattern.search(line): + stod_future.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "select-stringref-test" + + # List entities to find our select + entities, _ = await client.list_entities_services() + + select_entity = next( + (e for e in entities if hasattr(e, "options") and e.name == "Test Select"), + None, + ) + assert select_entity is not None, "Test Select entity not found" + + baud_entity = next( + (e for e in entities if hasattr(e, "options") and e.name == "Baud Rate"), + None, + ) + assert baud_entity is not None, "Baud Rate entity not found" + + # Change select to Option B - this should trigger on_value with StringRef + client.select_command(select_entity.key, "Option B") + # Change baud to 115200 - this tests ADL functions (stoi, stol, stof, stod) + client.select_command(baud_entity.key, "115200") + + # Wait for all log messages confirming StringRef operations work + try: + await asyncio.wait_for( + asyncio.gather( + value_logged_future, + concatenated_future, + comparison_future, + index_logged_future, + length_future, + find_substr_future, + find_char_future, + substr_future, + stoi_future, + stol_future, + stof_future, + stod_future, + ), + timeout=5.0, + ) + except TimeoutError: + results = { + "value_logged": value_logged_future.done(), + "concatenated": concatenated_future.done(), + "comparison": comparison_future.done(), + "index_logged": index_logged_future.done(), + "length": length_future.done(), + "find_substr": find_substr_future.done(), + "find_char": find_char_future.done(), + "substr": substr_future.done(), + "stoi": stoi_future.done(), + "stol": stol_future.done(), + "stof": stof_future.done(), + "stod": stod_future.done(), + } + pytest.fail(f"StringRef operations failed - received: {results}") From bfcc0e26a367625df7db7165172a4f0846b39421 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 22:22:44 -1000 Subject: [PATCH 45/51] [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 cf3ba50be0a6..3b0551b18431 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 ebfb9a7bbc9c..60f634275910 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 e3edda0e72c5..251e18648b64 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 20a145d1839e..a589f71c84c2 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 46/51] [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 e2b69ba8721a..8fac7a279c4a 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 5bdba1868713..7730506c1084 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 8015f2255a02..667d4218f3cc 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 f77ad326d973..b5519a052021 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 0e9c240547b3..574f1f5fb7c7 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 9d2df0956b1f..818abfc1d7c7 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 2757c5140535..114d25b9ce1b 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 47/51] [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 25512de4c7c5..0364879ccd75 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 48/51] [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 ce4febbe3791..fef4f1d85263 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 49/51] [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 a59cb8104b19..fd5bc9759654 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 0e91107beed2..26790b149346 100644 --- a/esphome/components/mipi_spi/models/adafruit.py +++ b/esphome/components/mipi_spi/models/adafruit.py @@ -26,5 +26,3 @@ 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 4d6c8da4b02b..32cad70ac0b1 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -105,6 +105,3 @@ (WCE, 0x00), ), ) - - -models = {} diff --git a/esphome/components/mipi_spi/models/cyd.py b/esphome/components/mipi_spi/models/cyd.py index a25ecf33a856..7229412f18c3 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 60a25c32a935..6b672b085943 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -148,6 +148,34 @@ ), ), ) + +# 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 @@ 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 5b936fd956fa..854814f57275 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -588,5 +588,3 @@ (0x29, 0x00), ), ) - -models = {} diff --git a/esphome/components/mipi_spi/models/lanbon.py b/esphome/components/mipi_spi/models/lanbon.py index 6f9aa5867495..8cec3c8317c8 100644 --- a/esphome/components/mipi_spi/models/lanbon.py +++ b/esphome/components/mipi_spi/models/lanbon.py @@ -11,5 +11,3 @@ 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 13ddc67465d0..46ec80902908 100644 --- a/esphome/components/mipi_spi/models/lilygo.py +++ b/esphome/components/mipi_spi/models/lilygo.py @@ -56,5 +56,3 @@ backlight_pin=48, invert_colors=True, ) - -models = {} From 520e69b991a37b358de3b12682e7d9c2ce9b623c Mon Sep 17 00:00:00 2001 From: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 20 Jan 2026 09:30:18 +1100 Subject: [PATCH 50/51] [serial_channel] Add a new entity type --- .../components/web_server/list_entities.cpp | 7 ++ esphome/components/web_server/list_entities.h | 3 + esphome/components/web_server/web_server.cpp | 83 +++++++++++++++++++ esphome/components/web_server/web_server.h | 13 +++ esphome/core/application.h | 16 ++++ esphome/core/component_iterator.cpp | 6 ++ esphome/core/component_iterator.h | 6 ++ esphome/core/controller.h | 6 ++ esphome/core/controller_registry.cpp | 4 + esphome/core/controller_registry.h | 10 +++ esphome/core/defines.h | 1 + 11 files changed, 155 insertions(+) diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index 845829806209..3089ad150390 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -127,6 +127,13 @@ bool ListEntitiesIterator::on_select(select::Select *obj) { } #endif +#ifdef USE_SERIAL_CHANNEL +bool ListEntitiesIterator::on_serial_channel(serial_channel::SerialChannel *obj) { + this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::serial_channel_all_json_generator); + return true; +} +#endif + #ifdef USE_ALARM_CONTROL_PANEL bool ListEntitiesIterator::on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *obj) { this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::alarm_control_panel_all_json_generator); diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index d0a4fa27256d..cd3c89a90de1 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -70,6 +70,9 @@ class ListEntitiesIterator : public ComponentIterator { #ifdef USE_SELECT bool on_select(select::Select *obj) override; #endif +#ifdef USE_SERIAL_CHANNEL + bool on_serial_channel(serial_channel::SerialChannel *obj) override; +#endif #ifdef USE_LOCK bool on_lock(lock::Lock *obj) override; #endif diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index e538a35e8cdd..cbad5067c1c2 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -157,6 +157,7 @@ EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const { } #endif + ESP_LOGD(TAG, "Id = '%.*s', Entity name = '%s'", (int) this->id.size(), this->id.c_str(), entity->get_name().c_str()); // Try matching by entity name (new format) if (this->id == entity->get_name()) { result.matched = true; @@ -1433,6 +1434,82 @@ std::string WebServer::select_json_(select::Select *obj, StringRef value, JsonDe } #endif +#ifdef USE_SERIAL_CHANNEL +void WebServer::on_serial_channel_update(serial_channel::SerialChannel *obj) { + if (!this->include_internal_ && obj->is_internal()) + return; + this->events_.deferrable_send_state(obj, "state", serial_channel_state_json_generator); +} +void WebServer::handle_serial_channel_request(AsyncWebServerRequest *request, const UrlMatch &match) { + for (auto *obj : App.get_serial_channels()) { + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) + continue; + + if (request->method() == HTTP_GET && entity_match.action_is_empty) { + auto detail = get_request_detail(request); + std::string data = this->serial_channel_json_(obj, obj->get_state(), detail); + request->send(200, "application/json", data.c_str()); + return; + } + + if (!match.method_equals(ESPHOME_F("send"))) { + request->send(404); + return; + } + + auto call = obj->make_call(); + parse_string_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_data); + + DEFER_ACTION(call, call.perform()); + request->send(200); + return; + } + request->send(404); +} + +std::string WebServer::serial_channel_state_json_generator(WebServer *web_server, void *source) { + return web_server->serial_channel_json_((serial_channel::SerialChannel *) (source), + ((serial_channel::SerialChannel *) (source))->get_state(), DETAIL_STATE); +} +std::string WebServer::serial_channel_all_json_generator(WebServer *web_server, void *source) { + return web_server->serial_channel_json_((serial_channel::SerialChannel *) (source), + ((serial_channel::SerialChannel *) (source))->get_state(), DETAIL_ALL); +} +std::string WebServer::serial_channel_json_(serial_channel::SerialChannel *obj, const std::string &value, + JsonDetail start_config) { + json::JsonBuilder builder; + JsonObject root = builder.root(); + + // the channel has no state to send here, don't duplicate. + set_json_icon_state_value(root, obj, "serial_channel", "", value.c_str(), start_config); + if (start_config == DETAIL_ALL) { + auto &traits = obj->traits; + root[ESPHOME_F("baud_rate")] = traits.get_baud_rate(); + root[ESPHOME_F("data_bits")] = traits.get_data_bits(); + root[ESPHOME_F("stop_bits")] = traits.get_stop_bits(); + + // Convert parity enum to string + const char *parity_str = "NONE"; + switch (traits.get_parity()) { + case uart::UART_CONFIG_PARITY_EVEN: + parity_str = "EVEN"; + break; + case uart::UART_CONFIG_PARITY_ODD: + parity_str = "ODD"; + break; + default: + break; + } + root[ESPHOME_F("parity")] = parity_str; + + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); +} +#endif + #ifdef USE_CLIMATE void WebServer::on_climate_update(climate::Climate *obj) { if (!this->include_internal_ && obj->is_internal()) @@ -2350,6 +2427,7 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { // Parse URL for component routing // Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action) UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST); + ESP_LOGD(TAG, "Handling request for URL: %s", url.c_str()); // Route to appropriate handler based on domain // NOLINTNEXTLINE(readability-simplify-boolean-expr) @@ -2425,6 +2503,11 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { this->handle_select_request(request, match); } #endif +#ifdef USE_SERIAL_CHANNEL + else if (match.domain_equals(ESPHOME_F("serial_channel"))) { + this->handle_serial_channel_request(request, match); + } +#endif #ifdef USE_CLIMATE else if (match.domain_equals(ESPHOME_F("climate"))) { this->handle_climate_request(request, match); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 92a5c7edee03..22c677c7a2c5 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -411,6 +411,15 @@ class WebServer : public Controller, static std::string select_all_json_generator(WebServer *web_server, void *source); #endif +#ifdef USE_SERIAL_CHANNEL + void on_serial_channel_update(serial_channel::SerialChannel *obj) override; + /// Handle a serial channel request under '/serial_channel/'. + void handle_serial_channel_request(AsyncWebServerRequest *request, const UrlMatch &match); + + static std::string serial_channel_state_json_generator(WebServer *web_server, void *source); + static std::string serial_channel_all_json_generator(WebServer *web_server, void *source); +#endif + #ifdef USE_CLIMATE void on_climate_update(climate::Climate *obj) override; /// Handle a climate request under '/climate/'. @@ -650,6 +659,10 @@ class WebServer : public Controller, #ifdef USE_SELECT std::string select_json_(select::Select *obj, StringRef value, JsonDetail start_config); #endif +#ifdef USE_SERIAL_CHANNEL + std::string serial_channel_json_(serial_channel::SerialChannel *obj, const std::string &value, + JsonDetail start_config); +#endif #ifdef USE_CLIMATE std::string climate_json_(climate::Climate *obj, JsonDetail start_config); #endif diff --git a/esphome/core/application.h b/esphome/core/application.h index 592bf809f1d3..e7c382bbfa01 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -76,6 +76,9 @@ #ifdef USE_SELECT #include "esphome/components/select/select.h" #endif +#ifdef USE_SERIAL_CHANNEL +#include "esphome/components/serial_channel/serial_channel.h" +#endif #ifdef USE_LOCK #include "esphome/components/lock/lock.h" #endif @@ -204,6 +207,12 @@ class Application { void register_select(select::Select *select) { this->selects_.push_back(select); } #endif +#ifdef USE_SERIAL_CHANNEL + void register_serial_channel(serial_channel::SerialChannel *serial_channel) { + this->serial_channels_.push_back(serial_channel); + } +#endif + #ifdef USE_LOCK void register_lock(lock::Lock *a_lock) { this->locks_.push_back(a_lock); } #endif @@ -441,6 +450,10 @@ class Application { auto &get_selects() const { return this->selects_; } GET_ENTITY_METHOD(select::Select, select, selects) #endif +#ifdef USE_SERIAL_CHANNEL + auto &get_serial_channels() const { return this->serial_channels_; } + GET_ENTITY_METHOD(serial_channel::SerialChannel, serial_channel, serial_channels) +#endif #ifdef USE_LOCK auto &get_locks() const { return this->locks_; } GET_ENTITY_METHOD(lock::Lock, lock, locks) @@ -649,6 +662,9 @@ class Application { #ifdef USE_SELECT StaticVector selects_{}; #endif +#ifdef USE_SERIAL_CHANNEL + std::vector serial_channels_{}; +#endif #ifdef USE_TEXT StaticVector texts_{}; #endif diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index ff76b2b81bfb..3baf04b80db5 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -139,6 +139,12 @@ void ComponentIterator::advance() { break; #endif +#ifdef USE_SERIAL_CHANNEL + case IteratorState::SERIAL_CHANNEL: + this->process_platform_item_(App.get_serial_channels(), &ComponentIterator::on_serial_channel); + break; +#endif + #ifdef USE_LOCK case IteratorState::LOCK: this->process_platform_item_(App.get_locks(), &ComponentIterator::on_lock); diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index e13d81a8e4f5..36c01936fb41 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -78,6 +78,9 @@ class ComponentIterator { #ifdef USE_SELECT virtual bool on_select(select::Select *select) = 0; #endif +#ifdef USE_SERIAL_CHANNEL + virtual bool on_serial_channel(serial_channel::SerialChannel *obj) = 0; +#endif #ifdef USE_LOCK virtual bool on_lock(lock::Lock *a_lock) = 0; #endif @@ -161,6 +164,9 @@ class ComponentIterator { #ifdef USE_SELECT SELECT, #endif +#ifdef USE_SERIAL_CHANNEL + SERIAL_CHANNEL, +#endif #ifdef USE_LOCK LOCK, #endif diff --git a/esphome/core/controller.h b/esphome/core/controller.h index 632b46c89373..f8a2f905f8d6 100644 --- a/esphome/core/controller.h +++ b/esphome/core/controller.h @@ -46,6 +46,9 @@ #ifdef USE_SELECT #include "esphome/components/select/select.h" #endif +#ifdef USE_SERIAL_CHANNEL +#include "esphome/components/serial_channel/serial_channel.h" +#endif #ifdef USE_LOCK #include "esphome/components/lock/lock.h" #endif @@ -114,6 +117,9 @@ class Controller { #ifdef USE_SELECT virtual void on_select_update(select::Select *obj){}; #endif +#ifdef USE_SERIAL_CHANNEL + virtual void on_serial_channel_update(serial_channel::SerialChannel *obj){}; +#endif #ifdef USE_LOCK virtual void on_lock_update(lock::Lock *obj){}; #endif diff --git a/esphome/core/controller_registry.cpp b/esphome/core/controller_registry.cpp index 13b505e8e95d..84c5180aebb7 100644 --- a/esphome/core/controller_registry.cpp +++ b/esphome/core/controller_registry.cpp @@ -82,6 +82,10 @@ CONTROLLER_REGISTRY_NOTIFY(text::Text, text) CONTROLLER_REGISTRY_NOTIFY(select::Select, select) #endif +#ifdef USE_SERIAL_CHANNEL +CONTROLLER_REGISTRY_NOTIFY(serial_channel::SerialChannel, serial_channel) +#endif + #ifdef USE_LOCK CONTROLLER_REGISTRY_NOTIFY(lock::Lock, lock) #endif diff --git a/esphome/core/controller_registry.h b/esphome/core/controller_registry.h index d6452d8827ea..131ec4049f49 100644 --- a/esphome/core/controller_registry.h +++ b/esphome/core/controller_registry.h @@ -95,6 +95,12 @@ class Select; } #endif +#ifdef USE_SERIAL_CHANNEL +namespace serial_channel { +class SerialChannel; +} +#endif + #ifdef USE_LOCK namespace lock { class Lock; @@ -218,6 +224,10 @@ class ControllerRegistry { static void notify_select_update(select::Select *obj); #endif +#ifdef USE_SERIAL_CHANNEL + static void notify_serial_channel_update(serial_channel::SerialChannel *obj); +#endif + #ifdef USE_LOCK static void notify_lock_update(lock::Lock *obj); #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c229d1df7d15..3669591288ab 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -107,6 +107,7 @@ #define USE_SAFE_MODE_CALLBACK #define USE_SELECT #define USE_SENSOR +#define USE_SERIAL_CHANNEL #define USE_STATUS_LED #define USE_STATUS_SENSOR #define USE_SWITCH From e3638acfd307ac91357219f759f7bda671ccf13f Mon Sep 17 00:00:00 2001 From: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 20 Jan 2026 09:33:45 +1100 Subject: [PATCH 51/51] Fix can_handle --- esphome/components/web_server/web_server.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index cbad5067c1c2..12b849964eb6 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -2355,6 +2355,10 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { if (match.domain_equals(ESPHOME_F("select"))) return true; #endif +#ifdef USE_SERIAL_CHANNEL + if (match.domain_equals(ESPHOME_F("serial_channel"))) + return true; +#endif #ifdef USE_CLIMATE if (match.domain_equals(ESPHOME_F("climate"))) return true;