1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-08 00:31:58 +00:00

[udp] Eliminate per-loop heap allocation by using std::span for packet callbacks

Replace std::vector<uint8_t> with std::array on the stack for the receive
buffer and std::span<const uint8_t> 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<uint8_t>& to
std::span<const uint8_t>, which preserves API compatibility for user
lambdas using data.size(), data.data(), data[i], and range-for.

The trigger/automation type remains std::vector<uint8_t> 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<const uint8_t> directly,
with implicit conversion from std::vector for other transports.
This commit is contained in:
J. Nick Koston
2026-02-07 10:15:56 +01:00
parent eb7aa3420f
commit f9542236e8
9 changed files with 61 additions and 19 deletions

View File

@@ -87,6 +87,7 @@ from esphome.cpp_types import ( # noqa: F401
size_t, size_t,
std_ns, std_ns,
std_shared_ptr, std_shared_ptr,
std_span,
std_string, std_string,
std_string_ref, std_string_ref,
std_vector, std_vector,

View File

@@ -396,9 +396,9 @@ static bool process_rolling_code(Provider &provider, PacketDecoder &decoder) {
/** /**
* Process a received packet * Process a received packet
*/ */
void PacketTransport::process_(const std::vector<uint8_t> &data) { void PacketTransport::process_(std::span<const uint8_t> data) {
auto ping_key_seen = !this->ping_pong_enable_; auto ping_key_seen = !this->ping_pong_enable_;
PacketDecoder decoder((data.data()), data.size()); PacketDecoder decoder(data.data(), data.size());
char namebuf[256]{}; char namebuf[256]{};
uint8_t byte; uint8_t byte;
FuData rdata{}; FuData rdata{};

View File

@@ -9,8 +9,9 @@
#include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h"
#endif #endif
#include <vector>
#include <map> #include <map>
#include <span>
#include <vector>
/** /**
* Providing packet encoding functions for exchanging data with a remote host. * 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; } virtual bool should_send() { return true; }
// to be called by child classes when a data packet is received. // to be called by child classes when a data packet is received.
void process_(const std::vector<uint8_t> &data); void process_(std::span<const uint8_t> data);
void send_data_(bool all); void send_data_(bool all);
void flush_(); void flush_();
void add_data_(uint8_t key, const char *id, float data); void add_data_(uint8_t key, const char *id, float data);

View File

@@ -23,8 +23,12 @@ MULTI_CONF = True
udp_ns = cg.esphome_ns.namespace("udp") udp_ns = cg.esphome_ns.namespace("udp")
UDPComponent = udp_ns.class_("UDPComponent", cg.Component) UDPComponent = udp_ns.class_("UDPComponent", cg.Component)
UDPWriteAction = udp_ns.class_("UDPWriteAction", automation.Action) UDPWriteAction = udp_ns.class_("UDPWriteAction", automation.Action)
trigger_args = cg.std_vector.template(cg.uint8)
trigger_argname = "data" 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)] trigger_argtype = [(trigger_args, trigger_argname)]
CONF_ADDRESSES = "addresses" CONF_ADDRESSES = "addresses"
@@ -118,7 +122,13 @@ async def to_code(config):
trigger_id, trigger_argtype, on_receive trigger_id, trigger_argtype, on_receive
) )
trigger_lambda = await cg.process_lambda( 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.add_listener(trigger_lambda))
cg.add(var.set_should_listen()) cg.add(var.set_should_listen())

View File

@@ -12,7 +12,7 @@ bool UDPTransport::should_send() { return network::is_connected(); }
void UDPTransport::setup() { void UDPTransport::setup() {
PacketTransport::setup(); PacketTransport::setup();
if (!this->providers_.empty() || this->is_encrypted_()) { if (!this->providers_.empty() || this->is_encrypted_()) {
this->parent_->add_listener([this](std::vector<uint8_t> &buf) { this->process_(buf); }); this->parent_->add_listener([this](std::span<const uint8_t> data) { this->process_(data); });
} }
} }

View File

@@ -103,8 +103,8 @@ void UDPComponent::setup() {
} }
void UDPComponent::loop() { void UDPComponent::loop() {
auto buf = std::vector<uint8_t>(MAX_PACKET_SIZE);
if (this->should_listen_) { if (this->should_listen_) {
std::array<uint8_t, MAX_PACKET_SIZE> buf;
for (;;) { for (;;) {
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
auto len = this->listen_socket_->read(buf.data(), buf.size()); auto len = this->listen_socket_->read(buf.data(), buf.size());
@@ -116,9 +116,9 @@ void UDPComponent::loop() {
#endif #endif
if (len <= 0) if (len <= 0)
break; break;
buf.resize(len); size_t packet_len = static_cast<size_t>(len);
ESP_LOGV(TAG, "Received packet of length %zu", len); ESP_LOGV(TAG, "Received packet of length %zu", packet_len);
this->packet_listeners_.call(buf); this->packet_listeners_.call(std::span<const uint8_t>(buf.data(), packet_len));
} }
} }
} }

View File

@@ -10,7 +10,9 @@
#ifdef USE_SOCKET_IMPL_LWIP_TCP #ifdef USE_SOCKET_IMPL_LWIP_TCP
#include <WiFiUdp.h> #include <WiFiUdp.h>
#endif #endif
#include <array>
#include <initializer_list> #include <initializer_list>
#include <span>
#include <vector> #include <vector>
namespace esphome::udp { namespace esphome::udp {
@@ -26,7 +28,7 @@ class UDPComponent : public Component {
void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; } void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; }
void set_should_broadcast() { this->should_broadcast_ = true; } void set_should_broadcast() { this->should_broadcast_ = true; }
void set_should_listen() { this->should_listen_ = true; } void set_should_listen() { this->should_listen_ = true; }
void add_listener(std::function<void(std::vector<uint8_t> &)> &&listener) { void add_listener(std::function<void(std::span<const uint8_t>)> &&listener) {
this->packet_listeners_.add(std::move(listener)); this->packet_listeners_.add(std::move(listener));
} }
void setup() override; void setup() override;
@@ -41,7 +43,7 @@ class UDPComponent : public Component {
uint16_t broadcast_port_{}; uint16_t broadcast_port_{};
bool should_broadcast_{}; bool should_broadcast_{};
bool should_listen_{}; bool should_listen_{};
CallbackManager<void(std::vector<uint8_t> &)> packet_listeners_{}; CallbackManager<void(std::span<const uint8_t>)> packet_listeners_{};
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
std::unique_ptr<socket::Socket> broadcast_socket_ = nullptr; std::unique_ptr<socket::Socket> broadcast_socket_ = nullptr;

View File

@@ -12,6 +12,7 @@ std_shared_ptr = std_ns.class_("shared_ptr")
std_string = std_ns.class_("string") std_string = std_ns.class_("string")
std_string_ref = std_ns.namespace("string &") std_string_ref = std_ns.namespace("string &")
std_vector = std_ns.class_("vector") std_vector = std_ns.class_("vector")
std_span = std_ns.class_("span")
uint8 = global_ns.namespace("uint8_t") uint8 = global_ns.namespace("uint8_t")
uint16 = global_ns.namespace("uint16_t") uint16 = global_ns.namespace("uint16_t")
uint32 = global_ns.namespace("uint32_t") uint32 = global_ns.namespace("uint32_t")

View File

@@ -93,23 +93,34 @@ async def udp_listener(port: int = 0) -> AsyncGenerator[tuple[int, UDPReceiver]]
sock.close() 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 @pytest.mark.asyncio
async def test_udp_send_receive( async def test_udp_send_receive(
yaml_config: str, yaml_config: str,
run_compiled: RunCompiledFunction, run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory, api_client_connected: APIClientConnectedFactory,
) -> None: ) -> None:
"""Test UDP component can send messages with multiple addresses configured.""" """Test UDP component can send and receive messages."""
# Track log lines to verify dump_config output
log_lines: list[str] = [] log_lines: list[str] = []
receive_event = asyncio.Event()
def on_log_line(line: str) -> None: def on_log_line(line: str) -> None:
log_lines.append(line) log_lines.append(line)
if "Received UDP:" in line:
receive_event.set()
async with udp_listener() as (udp_port, receiver): async with udp_listener() as (broadcast_port, receiver):
# Replace placeholders in the config listen_port = _get_free_udp_port()
config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(udp_port + 1)) config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port))
config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(udp_port)) config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(broadcast_port))
async with ( async with (
run_compiled(config, line_callback=on_log_line), 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, ( assert "Address: 127.0.0.2" in log_text, (
f"Address 127.0.0.2 not found in dump_config. Log: {log_text[-2000:]}" 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:]}"
)