From 4d4283bcfa1fa9f7d1139a45206418b476b07a7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 15:50:23 -1000 Subject: [PATCH 01/39] [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 69abf4b989e..9be196d4207 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 4474efeb776..947a59dfa93 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 065789ae28d..9967e4dbbb7 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 98546d49ef5..3466e8d2ee0 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 00000000000..155d9327226 --- /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 00000000000..74c7ef60e34 --- /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 02/39] [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 163fbf4eb2a..ebfb9a7bbc9 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 b4b8000962c..66eda8e3916 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 649d9516186..512587511b1 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 11ff6c853ad..bb62d4794a5 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 03/39] [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 9345a32223c..c3b60ffd65e 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 04/39] [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 32ed8f047bd..5acbb6e15ac 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 178b410ed29..e5e5e4f4a8b 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 05/39] [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 | 61 ++++++++++++------- 3 files changed, 91 insertions(+), 56 deletions(-) diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 50c79802153..124d9a8c328 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -30,6 +30,7 @@ _WG_KEY_REGEX = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$") 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 7810a40ae1d..2022e25b6cf 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 f8f79b835d8..e8470c75cd9 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 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; } - void add_allowed_ip(const std::string &ip, const std::string &netmask); + /// 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 06/39] [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 256c7f276a2..59624a5f53f 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 50e43da8d58..028d6f08796 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 2038d8f1b06..df3b64fb6e9 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 142bf3c7e61..39d5739255e 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 07/39] [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 634260b5e9f..f4c199cb987 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 409c691cb10..cdc051fc90c 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 08/39] [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 3384a0572f1..197b516bb29 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 a27c14106d5..4440d9414e1 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 7bcb817f097..20671a58155 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 dd0dc8d4956..a75b85dc8e2 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 09/39] [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 6ed05715cb0..fca29265682 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 89e86741b0d..b26411b72e7 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 10/39] [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 b719d1a70e7..3dfcf0cb640 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 11/39] [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 7794187a69b..4e4bd31f9bb 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 4e3761675da..44c20845e8a 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 cdc051fc90c..8b581eb97a3 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 12/39] [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 0804985cc59..25512de4c7c 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 f8c7a1b40bd..c1e7336ce40 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 1acbe506a38..3de5d5ca7b7 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 8b581eb97a3..4185596c7bc 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 8fe23b9a193..2d77821ff33 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 3fe0dfe0453..b4adedf8738 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 @@ async def test_api_homeassistant( 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 @@ async def test_api_homeassistant( 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 @@ async def test_api_homeassistant( 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 13/39] [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 c1a4a52ce2b..f0c7c87e17e 100644 --- a/esphome/components/status/binary_sensor.py +++ b/esphome/components/status/binary_sensor.py @@ -7,14 +7,14 @@ DEPENDENCIES = ["network"] 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 1795a9c41b7..2c95be85690 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 feda8b6328d..7e8c31d7415 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 14/39] [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 c92e33393b2..bb94ecb6756 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 9f9f61de85e..d74804fdb00 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 15/39] [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 b51a57d68ee..5490154d010 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 16/39] [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 23e92be65a3..40da4498148 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 17/39] [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 c487f9f50bf..097b3c1af82 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 18/39] [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 dd1443d10c0..f3f578794a6 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 19/39] [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 d77af510d79..354288e1a34 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 d210bad004f..220c75f9d39 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 712fa8e78e5..c4e6ff71821 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 20/39] [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 85e5a2da032..16e37949dc7 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 21/39] [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 83ad6b2720f..376de54db47 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 22/39] [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 84355f2793a..9ff01b32b21 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 23/39] [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 200ca567c22..784b87916b2 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 24/39] [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 36b6d630ae3..b15fb6f85a7 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 25/39] [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 b7b3273f398..acd3980a1ad 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 26/39] [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 d031c31294f..eef95b9ba28 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 7a5ee08984b..659233443e2 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 13fe5a53230..6ba17f11d11 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 87c26689625..56b2ecf9f4e 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 302f6b8280b..535119fc5c1 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 4b358e384cc..cd9c81fe034 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 909bac5f054..f393af88cea 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 a6f9c2396c0..c574ce6593a 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 8bdd312ab9b..b7d3848f0eb 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 f3a1f013d97..fdff11dedb4 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 718e0b86ed3..63b3f98521a 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 6b5c5d9fc4d..1bb8c7936db 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 41a99eba4ba..a7e5d0514d9 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 376de6a3708..79c52538983 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 197b516bb29..d0a8f8366b6 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 dd6c8188ebd..cfcbeeeb8d7 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 27/39] [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 d69a438578a..a82ee48718b 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 28/39] [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 0ab5b238b54..7d4050284f1 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 8e4b3437ab6..3b9290259be 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 44c20845e8a..e7b901d71f3 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 4185596c7bc..bd3a4def059 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 e227ec873e9..ee2e73872c3 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -688,6 +688,7 @@ HEAP_ALLOCATING_HELPERS = { "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 @@ HEAP_ALLOCATING_HELPERS = { 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 29/39] [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 92ecd5ea399..00ea88ff16b 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 30/39] [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 61b9ea51633..e538a35e8cd 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 31/39] [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 f05342f4826..47263108065 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 32/39] [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 e26535e9855..057713b8845 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 33/39] [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 08b361b8fb4..311133a813b 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 34/39] [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 71fe15f0ae0..4432195365d 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 35/39] [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 2e92c58e29b..e4036021df7 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 00dd98fc80b..f1a2802cbd7 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 36/39] [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 8bb6ddf942d..2c44c6fba97 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 37/39] [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 5efa70d6b41..7b5e78af520 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 38/39] [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 617e2138fb1..ddf38f2f55f 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 39/39] [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 8c2e349180f..7de1023e6df 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; }