From c82cef3b64b032f1828356af5098bedec00eccba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jan 2026 13:09:57 -1000 Subject: [PATCH] [udp] Store addresses in flash instead of heap --- 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:]}" + )