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/12] [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 69abf4b989..9be196d420 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 4474efeb77..947a59dfa9 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 065789ae28..9967e4dbbb 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 98546d49ef..3466e8d2ee 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 0000000000..155d932722 --- /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 0000000000..74c7ef60e3 --- /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/12] [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 163fbf4eb2..ebfb9a7bbc 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 b4b8000962..66eda8e391 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 649d951618..512587511b 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 11ff6c853a..bb62d4794a 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/12] [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 9345a32223..c3b60ffd65 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/12] [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 32ed8f047b..5acbb6e15a 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 178b410ed2..e5e5e4f4a8 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/12] [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 50c7980215..124d9a8c32 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 7810a40ae1..2022e25b6c 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 f8f79b835d..e8470c75cd 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/12] [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 256c7f276a..59624a5f53 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 50e43da8d5..028d6f0879 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 2038d8f1b0..df3b64fb6e 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 142bf3c7e6..39d5739255 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/12] [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 634260b5e9..f4c199cb98 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 409c691cb1..cdc051fc90 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/12] [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 3384a0572f..197b516bb2 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 a27c14106d..4440d9414e 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 7bcb817f09..20671a5815 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 dd0dc8d495..a75b85dc8e 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/12] [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 6ed05715cb..fca2926568 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 89e86741b0..b26411b72e 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/12] [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 b719d1a70e..3dfcf0cb64 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/12] [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 7794187a69..4e4bd31f9b 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 4e3761675d..44c20845e8 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 cdc051fc90..8b581eb97a 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 4ed68c68849e907cf6bed0949546434dd0b871ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jan 2026 12:16:18 -1000 Subject: [PATCH 12/12] [wifi] ESP8266: Use direct SDK calls to reduce flash and heap allocation --- .../wifi/wifi_component_esp8266.cpp | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 6fb5dd5769..de0600cf5b 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -920,7 +920,16 @@ bssid_t WiFiComponent::wifi_bssid() { } return bssid; } -std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } +std::string WiFiComponent::wifi_ssid() { + struct station_config conf {}; + if (!wifi_station_get_config(&conf)) { + return ""; + } + // conf.ssid is uint8[32], not null-terminated if full + auto *ssid_s = reinterpret_cast(conf.ssid); + size_t len = strnlen(ssid_s, sizeof(conf.ssid)); + return {ssid_s, len}; +} const char *WiFiComponent::wifi_ssid_to(std::span buffer) { struct station_config conf {}; if (!wifi_station_get_config(&conf)) { @@ -934,16 +943,24 @@ const char *WiFiComponent::wifi_ssid_to(std::span buffer return buffer.data(); } int8_t WiFiComponent::wifi_rssi() { - if (WiFi.status() != WL_CONNECTED) + if (wifi_station_get_connect_status() != STATION_GOT_IP) return WIFI_RSSI_DISCONNECTED; - int8_t rssi = WiFi.RSSI(); + sint8 rssi = wifi_station_get_rssi(); // Values >= 31 are error codes per NONOS SDK API, not valid RSSI readings return rssi >= 31 ? WIFI_RSSI_DISCONNECTED : rssi; } -int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); } -network::IPAddress WiFiComponent::wifi_subnet_mask_() { return {(const ip_addr_t *) WiFi.subnetMask()}; } -network::IPAddress WiFiComponent::wifi_gateway_ip_() { return {(const ip_addr_t *) WiFi.gatewayIP()}; } -network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return {(const ip_addr_t *) WiFi.dnsIP(num)}; } +int32_t WiFiComponent::get_wifi_channel() { return wifi_get_channel(); } +network::IPAddress WiFiComponent::wifi_subnet_mask_() { + struct ip_info ip {}; + wifi_get_ip_info(STATION_IF, &ip); + return network::IPAddress(&ip.netmask); +} +network::IPAddress WiFiComponent::wifi_gateway_ip_() { + struct ip_info ip {}; + wifi_get_ip_info(STATION_IF, &ip); + return network::IPAddress(&ip.gw); +} +network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(dns_getserver(num)); } void WiFiComponent::wifi_loop_() {} } // namespace esphome::wifi