From f9542236e8ac4e2cc3982762202d2dfdb1c01a6d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Feb 2026 10:15:56 +0100 Subject: [PATCH] [udp] Eliminate per-loop heap allocation by using std::span for packet callbacks Replace std::vector with std::array on the stack for the receive buffer and std::span for packet listener callbacks. This eliminates a 508-byte heap allocation on every loop() iteration, reducing heap fragmentation on long-running devices. The callback signature changes from std::vector& to std::span, which preserves API compatibility for user lambdas using data.size(), data.data(), data[i], and range-for. The trigger/automation type remains std::vector so that deferred actions (e.g. delay) safely own their data. The listener lambda converts span to vector at the trigger boundary. PacketTransport::process_() now takes std::span directly, with implicit conversion from std::vector for other transports. --- esphome/codegen.py | 1 + .../packet_transport/packet_transport.cpp | 4 +- .../packet_transport/packet_transport.h | 5 ++- esphome/components/udp/__init__.py | 14 ++++++- .../udp/packet_transport/udp_transport.cpp | 2 +- esphome/components/udp/udp_component.cpp | 8 ++-- esphome/components/udp/udp_component.h | 6 ++- esphome/cpp_types.py | 1 + tests/integration/test_udp.py | 39 ++++++++++++++++--- 9 files changed, 61 insertions(+), 19 deletions(-) diff --git a/esphome/codegen.py b/esphome/codegen.py index 4a2a5975c6..c5283f4967 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -87,6 +87,7 @@ from esphome.cpp_types import ( # noqa: F401 size_t, std_ns, std_shared_ptr, + std_span, std_string, std_string_ref, std_vector, diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index cefe9a604e..365a5f2ec7 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -396,9 +396,9 @@ static bool process_rolling_code(Provider &provider, PacketDecoder &decoder) { /** * Process a received packet */ -void PacketTransport::process_(const std::vector &data) { +void PacketTransport::process_(std::span data) { auto ping_key_seen = !this->ping_pong_enable_; - PacketDecoder decoder((data.data()), data.size()); + PacketDecoder decoder(data.data(), data.size()); char namebuf[256]{}; uint8_t byte; FuData rdata{}; diff --git a/esphome/components/packet_transport/packet_transport.h b/esphome/components/packet_transport/packet_transport.h index 86ec564fce..57f40874b5 100644 --- a/esphome/components/packet_transport/packet_transport.h +++ b/esphome/components/packet_transport/packet_transport.h @@ -9,8 +9,9 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif -#include #include +#include +#include /** * Providing packet encoding functions for exchanging data with a remote host. @@ -113,7 +114,7 @@ class PacketTransport : public PollingComponent { virtual bool should_send() { return true; } // to be called by child classes when a data packet is received. - void process_(const std::vector &data); + void process_(std::span data); void send_data_(bool all); void flush_(); void add_data_(uint8_t key, const char *id, float data); diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 8252e35023..c676bdfa24 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -23,8 +23,12 @@ MULTI_CONF = True udp_ns = cg.esphome_ns.namespace("udp") UDPComponent = udp_ns.class_("UDPComponent", cg.Component) UDPWriteAction = udp_ns.class_("UDPWriteAction", automation.Action) -trigger_args = cg.std_vector.template(cg.uint8) trigger_argname = "data" +# Listener callback type (non-owning span from UDP component) +listener_args = cg.std_span.template(cg.uint8.operator("const")) +listener_argtype = [(listener_args, trigger_argname)] +# Automation/trigger type (owned vector, safe for deferred actions like delay) +trigger_args = cg.std_vector.template(cg.uint8) trigger_argtype = [(trigger_args, trigger_argname)] CONF_ADDRESSES = "addresses" @@ -118,7 +122,13 @@ async def to_code(config): trigger_id, trigger_argtype, on_receive ) trigger_lambda = await cg.process_lambda( - trigger.trigger(literal(trigger_argname)), trigger_argtype + trigger.trigger( + cg.std_vector.template(cg.uint8)( + literal(f"{trigger_argname}.begin()"), + literal(f"{trigger_argname}.end()"), + ) + ), + listener_argtype, ) cg.add(var.add_listener(trigger_lambda)) cg.add(var.set_should_listen()) diff --git a/esphome/components/udp/packet_transport/udp_transport.cpp b/esphome/components/udp/packet_transport/udp_transport.cpp index f3e33573a5..b5e73af777 100644 --- a/esphome/components/udp/packet_transport/udp_transport.cpp +++ b/esphome/components/udp/packet_transport/udp_transport.cpp @@ -12,7 +12,7 @@ bool UDPTransport::should_send() { return network::is_connected(); } void UDPTransport::setup() { PacketTransport::setup(); if (!this->providers_.empty() || this->is_encrypted_()) { - this->parent_->add_listener([this](std::vector &buf) { this->process_(buf); }); + this->parent_->add_listener([this](std::span data) { this->process_(data); }); } } diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 947a59dfa9..c144212ecf 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -103,8 +103,8 @@ void UDPComponent::setup() { } void UDPComponent::loop() { - auto buf = std::vector(MAX_PACKET_SIZE); if (this->should_listen_) { + std::array buf; for (;;) { #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) auto len = this->listen_socket_->read(buf.data(), buf.size()); @@ -116,9 +116,9 @@ void UDPComponent::loop() { #endif if (len <= 0) break; - buf.resize(len); - ESP_LOGV(TAG, "Received packet of length %zu", len); - this->packet_listeners_.call(buf); + size_t packet_len = static_cast(len); + ESP_LOGV(TAG, "Received packet of length %zu", packet_len); + this->packet_listeners_.call(std::span(buf.data(), packet_len)); } } } diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index 9967e4dbbb..7fd6308065 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -10,7 +10,9 @@ #ifdef USE_SOCKET_IMPL_LWIP_TCP #include #endif +#include #include +#include #include namespace esphome::udp { @@ -26,7 +28,7 @@ class UDPComponent : public Component { void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; } void set_should_broadcast() { this->should_broadcast_ = true; } void set_should_listen() { this->should_listen_ = true; } - void add_listener(std::function &)> &&listener) { + void add_listener(std::function)> &&listener) { this->packet_listeners_.add(std::move(listener)); } void setup() override; @@ -41,7 +43,7 @@ class UDPComponent : public Component { uint16_t broadcast_port_{}; bool should_broadcast_{}; bool should_listen_{}; - CallbackManager &)> packet_listeners_{}; + CallbackManager)> packet_listeners_{}; #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) std::unique_ptr broadcast_socket_ = nullptr; diff --git a/esphome/cpp_types.py b/esphome/cpp_types.py index 7001c38857..6d255bc0be 100644 --- a/esphome/cpp_types.py +++ b/esphome/cpp_types.py @@ -12,6 +12,7 @@ std_shared_ptr = std_ns.class_("shared_ptr") std_string = std_ns.class_("string") std_string_ref = std_ns.namespace("string &") std_vector = std_ns.class_("vector") +std_span = std_ns.class_("span") uint8 = global_ns.namespace("uint8_t") uint16 = global_ns.namespace("uint16_t") uint32 = global_ns.namespace("uint32_t") diff --git a/tests/integration/test_udp.py b/tests/integration/test_udp.py index 74c7ef60e3..2187d13814 100644 --- a/tests/integration/test_udp.py +++ b/tests/integration/test_udp.py @@ -93,23 +93,34 @@ async def udp_listener(port: int = 0) -> AsyncGenerator[tuple[int, UDPReceiver]] sock.close() +def _get_free_udp_port() -> int: + """Get a free UDP port by binding to port 0 and releasing.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + @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 + """Test UDP component can send and receive messages.""" log_lines: list[str] = [] + receive_event = asyncio.Event() def on_log_line(line: str) -> None: log_lines.append(line) + if "Received UDP:" in line: + receive_event.set() - 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 udp_listener() as (broadcast_port, receiver): + listen_port = _get_free_udp_port() + config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port)) + config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(broadcast_port)) async with ( run_compiled(config, line_callback=on_log_line), @@ -169,3 +180,19 @@ async def test_udp_send_receive( assert "Address: 127.0.0.2" in log_text, ( f"Address 127.0.0.2 not found in dump_config. Log: {log_text[-2000:]}" ) + + # Test receiving a UDP packet (exercises on_receive with std::span) + test_payload = b"TEST_RECEIVE_UDP" + send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + send_sock.sendto(test_payload, ("127.0.0.1", listen_port)) + finally: + send_sock.close() + + try: + await asyncio.wait_for(receive_event.wait(), timeout=5.0) + except TimeoutError: + pytest.fail( + f"on_receive did not fire. Expected 'Received UDP:' in logs. " + f"Last log lines: {log_lines[-20:]}" + )