Merge branch 'udp_flash_strings' into integration

This commit is contained in:
J. Nick Koston
2026-01-17 13:10:45 -10:00
9 changed files with 350 additions and 17 deletions
+1 -2
View File
@@ -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])
+5 -7
View File
@@ -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
+8 -6
View File
@@ -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 <WiFiUdp.h>
#endif
#include <initializer_list>
#include <vector>
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<const char *> addresses) { this->addresses_ = addresses; }
/// Prevent accidental use of std::string which would dangle
void set_addresses(std::initializer_list<std::string> 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<IPAddress> ipaddrs_{};
WiFiUDP udp_client_{};
#endif
std::vector<std::string> addresses_{};
FixedVector<const char *> addresses_{};
optional<network::IPAddress> listen_address_{};
};
} // namespace udp
} // namespace esphome
} // namespace esphome::udp
#endif
@@ -143,7 +143,7 @@ bool ListEntitiesIterator::on_water_heater(water_heater::WaterHeater *obj) {
#ifdef USE_INFRARED
bool ListEntitiesIterator::on_infrared(infrared::Infrared *obj) {
// Infrared web_server support not yet implemented - this stub acknowledges the entity
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::infrared_all_json_generator);
return true;
}
#endif
@@ -33,6 +33,10 @@
#include "esphome/components/water_heater/water_heater.h"
#endif
#ifdef USE_INFRARED
#include "esphome/components/infrared/infrared.h"
#endif
#ifdef USE_WEBSERVER_LOCAL
#if USE_WEBSERVER_VERSION == 2
#include "server_index_v2.h"
@@ -1939,6 +1943,110 @@ std::string WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDe
}
#endif
#ifdef USE_INFRARED
void WebServer::handle_infrared_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (infrared::Infrared *obj : App.get_infrareds()) {
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->infrared_json_(obj, detail);
request->send(200, ESPHOME_F("application/json"), data.c_str());
return;
}
if (!match.method_equals(ESPHOME_F("transmit"))) {
request->send(404);
return;
}
// Only allow transmit if the device supports it
if (!obj->has_transmitter()) {
request->send(400, ESPHOME_F("text/plain"), "Device does not support transmission");
return;
}
// Parse parameters
auto call = obj->make_call();
// Parse carrier frequency (optional)
if (request->hasParam(ESPHOME_F("carrier_frequency"))) {
auto value = parse_number<uint32_t>(request->getParam(ESPHOME_F("carrier_frequency"))->value().c_str());
if (value.has_value()) {
call.set_carrier_frequency(*value);
}
}
// Parse repeat count (optional, defaults to 1)
if (request->hasParam(ESPHOME_F("repeat_count"))) {
auto value = parse_number<uint32_t>(request->getParam(ESPHOME_F("repeat_count"))->value().c_str());
if (value.has_value()) {
call.set_repeat_count(*value);
}
}
// Parse base64url-encoded raw timings (required)
// Base64url is URL-safe: uses A-Za-z0-9-_ (no special characters needing escaping)
if (!request->hasParam(ESPHOME_F("data"))) {
request->send(400, ESPHOME_F("text/plain"), "Missing 'data' parameter");
return;
}
// .c_str() is required for Arduino framework where value() returns Arduino String instead of std::string
std::string encoded =
request->getParam(ESPHOME_F("data"))->value().c_str(); // NOLINT(readability-redundant-string-cstr)
// Validate base64url is not empty
if (encoded.empty()) {
request->send(400, ESPHOME_F("text/plain"), "Empty 'data' parameter");
return;
}
#ifdef USE_ESP8266
// ESP8266 is single-threaded, call directly
call.set_raw_timings_base64url(encoded);
call.perform();
#else
// Defer to main loop for thread safety. Move encoded string into lambda to ensure
// it outlives the call - set_raw_timings_base64url stores a pointer, so the string
// must remain valid until perform() completes.
this->defer([call, encoded = std::move(encoded)]() mutable {
call.set_raw_timings_base64url(encoded);
call.perform();
});
#endif
request->send(200);
return;
}
request->send(404);
}
std::string WebServer::infrared_all_json_generator(WebServer *web_server, void *source) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
return web_server->infrared_json_(static_cast<infrared::Infrared *>(source), DETAIL_ALL);
}
std::string WebServer::infrared_json_(infrared::Infrared *obj, JsonDetail start_config) {
json::JsonBuilder builder;
JsonObject root = builder.root();
set_json_icon_state_value(root, obj, "infrared", "", 0, start_config);
auto traits = obj->get_traits();
root[ESPHOME_F("supports_transmitter")] = traits.get_supports_transmitter();
root[ESPHOME_F("supports_receiver")] = traits.get_supports_receiver();
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
}
return builder.serialize();
}
#endif
#ifdef USE_EVENT
void WebServer::on_event(event::Event *obj) {
if (!this->include_internal_ && obj->is_internal())
@@ -2193,6 +2301,10 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const {
#ifdef USE_WATER_HEATER
if (match.domain_equals(ESPHOME_F("water_heater")))
return true;
#endif
#ifdef USE_INFRARED
if (match.domain_equals(ESPHOME_F("infrared")))
return true;
#endif
}
@@ -2342,6 +2454,11 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
else if (match.domain_equals(ESPHOME_F("water_heater"))) {
this->handle_water_heater_request(request, match);
}
#endif
#ifdef USE_INFRARED
else if (match.domain_equals(ESPHOME_F("infrared"))) {
this->handle_infrared_request(request, match);
}
#endif
else {
// No matching handler found - send 404
@@ -460,6 +460,13 @@ class WebServer : public Controller,
static std::string water_heater_all_json_generator(WebServer *web_server, void *source);
#endif
#ifdef USE_INFRARED
/// Handle an infrared request under '/infrared/<id>/transmit'.
void handle_infrared_request(AsyncWebServerRequest *request, const UrlMatch &match);
static std::string infrared_all_json_generator(WebServer *web_server, void *source);
#endif
#ifdef USE_EVENT
void on_event(event::Event *obj) override;
@@ -662,6 +669,9 @@ class WebServer : public Controller,
#ifdef USE_WATER_HEATER
std::string water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config);
#endif
#ifdef USE_INFRARED
std::string infrared_json_(infrared::Infrared *obj, JsonDetail start_config);
#endif
#ifdef USE_UPDATE
std::string update_json_(update::UpdateEntity *obj, JsonDetail start_config);
#endif
+4 -1
View File
@@ -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"
@@ -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()]
+171
View File
@@ -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<const char*> 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:]}"
)