[udp] Add IPv6 unicast and multicast (#20590)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
rwrozelle
2026-10-12 11:17:58 -10:00
committed by GitHub
co-authored by J. Nick Koston Claude Opus 5.5 J. Nick Koston
parent ffa849753a
commit 0b6856c052
13 changed files with 592 additions and 108 deletions
+3 -3
View File
@@ -263,8 +263,7 @@ bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_
}
return true;
}
// The raw lwIP implementation has no IPv6 membership support
#if USE_NETWORK_IPV6 && (defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS))
#ifdef USE_SOCKET_IPV6
struct ipv6_mreq imreq6 {};
#ifdef USE_SOCKET_IMPL_BSD_SOCKETS
#ifdef USE_ZEPHYR
@@ -315,7 +314,7 @@ bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_
#else
errno = EINVAL;
return false;
#endif // USE_NETWORK_IPV6 && fd based sockets
#endif // USE_SOCKET_IPV6
}
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
@@ -335,6 +334,7 @@ bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in) {
return true;
});
if (ifindex == 0) {
errno = EADDRNOTAVAIL;
return false;
}
}
+5
View File
@@ -56,6 +56,11 @@ inline constexpr int IP_DOMAIN = AF_INET6;
inline constexpr int IP_DOMAIN = AF_INET;
#endif
// IPv6 sockets with multicast membership; the raw lwIP implementation has no IPv6 membership support.
#if USE_NETWORK_IPV6 && (defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS))
#define USE_SOCKET_IPV6
#endif
#ifdef USE_LWIP_FAST_SELECT
/// Shared ready() helper using cached lwip_sock pointer for direct rcvevent read.
/// cached_sock == nullptr means the socket is not monitored (monitor_loop was false, fd
+89 -8
View File
@@ -1,9 +1,11 @@
from ipaddress import IPv4Address, IPv6Address
from typing import Any
from esphome import automation
from esphome.automation import Trigger
import esphome.codegen as cg
from esphome.components.const import CONF_ON_RECEIVE
from esphome.components.network import DOMAIN as NETWORK_DOMAIN
from esphome.components.packet_transport import (
CONF_BINARY_SENSORS,
CONF_ENCRYPTION,
@@ -13,9 +15,16 @@ from esphome.components.packet_transport import (
CONF_SENSORS,
)
import esphome.config_validation as cv
from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID
from esphome.const import (
CONF_DATA,
CONF_ENABLE_IPV6,
CONF_ID,
CONF_PORT,
CONF_TRIGGER_ID,
)
from esphome.core import ID
from esphome.cpp_generator import MockObj, TemplateArgsType
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@clydebarrow"]
@@ -63,13 +72,80 @@ RELOCATED = {
}
def _consume_udp_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for UDP component."""
def _no_scope_id(addr: IPv4Address | IPv6Address) -> IPv4Address | IPv6Address:
"""Python accepts a scope id, which neither inet_pton nor lwIP's inet6_aton takes."""
if isinstance(addr, IPv6Address) and addr.scope_id is not None:
raise cv.Invalid(
f"{addr} carries a scope id, which the device cannot parse; "
"give the address without the '%' part"
)
return addr
def _address(value: Any) -> IPv4Address | IPv6Address:
addr = _no_scope_id(cv.ipaddress(value))
# A link-local destination needs a scope id, which the device cannot pick on every platform
if isinstance(addr, IPv6Address) and addr.is_link_local:
raise cv.Invalid(
f"{addr} is link-local; use a multicast, unique local or global address"
)
return addr
def _listen_address(value: Any) -> IPv4Address | IPv6Address:
"""Accept an IPv4 multicast/broadcast or an IPv6 multicast address, with a family-specific error."""
if ":" not in str(value):
return cv.ipv4address_multi_broadcast(value)
addr = _no_scope_id(cv.ipv6address(value))
if not addr.is_multicast:
raise cv.Invalid(f"{value} is not an IPv6 multicast address")
return addr
def _final_validate(config: ConfigType) -> None:
from esphome.components import socket
# UDP uses up to 2 sockets: 1 broadcast + 1 listen
# Whether each is used depends on code generation, so register worst case
socket.consume_sockets(2, "udp", socket.SocketType.UDP)(config)
full_config = fv.full_config.get()
ipv6_enabled = full_config.get(NETWORK_DOMAIN, {}).get(CONF_ENABLE_IPV6, False)
raw_lwip = (
full_config.get(socket.DOMAIN, {}).get(socket.CONF_IMPLEMENTATION)
== socket.IMPLEMENTATION_LWIP_TCP
)
for key, addresses in (
(CONF_ADDRESSES, config[CONF_ADDRESSES]),
(CONF_LISTEN_ADDRESS, [config[CONF_LISTEN_ADDRESS]]),
):
for addr in addresses:
if addr.version != 6:
continue
if raw_lwip:
raise cv.Invalid(
f"IPv6 address '{addr}' is not supported on this platform", [key]
)
if not ipv6_enabled:
raise cv.Invalid(
f"IPv6 address '{addr}' requires 'enable_ipv6: true' in the 'network' component",
[key],
)
FINAL_VALIDATE_SCHEMA = _final_validate
def _ipv6_addresses(config: ConfigType) -> list[IPv6Address]:
return [addr for addr in config[CONF_ADDRESSES] if addr.version == 6]
def _has_ipv4_address(config: ConfigType) -> bool:
return any(addr.version == 4 for addr in config[CONF_ADDRESSES])
def _consume_udp_sockets(config: ConfigType) -> ConfigType:
"""Reserve the listen socket plus one send socket per address family in use."""
from esphome.components import socket
count = 1 + _has_ipv4_address(config) + bool(_ipv6_addresses(config))
socket.consume_sockets(count, "udp", socket.SocketType.UDP)(config)
return config
@@ -88,9 +164,9 @@ CONFIG_SCHEMA = cv.All(
),
cv.Optional(
CONF_LISTEN_ADDRESS, default="255.255.255.255"
): cv.ipv4address_multi_broadcast,
): _listen_address,
cv.Optional(CONF_ADDRESSES, default=["255.255.255.255"]): cv.ensure_list(
cv.ipv4address,
_address
),
cv.Optional(CONF_ON_RECEIVE): automation.validate_automation(
{
@@ -125,6 +201,11 @@ async def to_code(config: ConfigType) -> None:
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))
if ipv6_addresses := _ipv6_addresses(config):
cg.add_define("USE_UDP_IPV6_SEND")
cg.add(var.set_ipv6_send(any(addr.is_multicast for addr in ipv6_addresses)))
if not _has_ipv4_address(config):
cg.add(var.set_ipv4_send(False))
# Shared flash table ended by nullptr, so the component stores only a pointer.
if addresses := [str(addr) for addr in config[CONF_ADDRESSES]]:
cg.add(
+168 -60
View File
@@ -8,44 +8,95 @@
namespace esphome::udp {
ESPHOME_LOG_TAG(TAG, "udp");
static constexpr uint32_t JOIN_POLL_ID = 0;
static constexpr uint32_t JOIN_POLL_INTERVAL_MS = 1000;
static constexpr uint32_t NETWORK_POLL_ID = 0;
static constexpr uint32_t NETWORK_POLL_INTERVAL_MS = 1000;
void UDPComponent::setup() {
size_t address_count = 0;
for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++)
address_count++;
this->sockaddrs_.init(address_count);
for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) {
const char *address = *it;
struct sockaddr saddr {};
if (socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_) == 0) {
SockaddrEntry entry{};
if (socket::set_sockaddr(&entry.sa, sizeof(entry), address, this->broadcast_port_) == 0) {
ESP_LOGW(TAG, "Invalid address %s", address);
// A dropped address silently receives nothing; surface the misconfiguration
this->status_set_warning(LOG_STR("invalid address"));
continue;
}
this->sockaddrs_.push_back(saddr);
this->sockaddrs_.push_back(entry);
}
// set up broadcast socket
if (this->should_broadcast_) {
this->broadcast_socket_ = socket::socket_udp_send(AF_INET, IPPROTO_IP);
if (this->broadcast_socket_ == nullptr) {
this->status_set_error(LOG_STR("Could not create socket"));
this->mark_failed();
return;
#ifdef USE_UDP_IPV6_SEND
if (this->ipv4_send_)
#endif
{
this->broadcast_socket_ = socket::socket_udp_send(AF_INET, IPPROTO_IP);
if (this->broadcast_socket_ == nullptr) {
this->status_set_error(LOG_STR("Could not create socket"));
this->mark_failed();
return;
}
int enable = 1;
auto err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
if (err != 0) {
this->status_set_warning(LOG_STR("Socket unable to set reuseaddr"));
// we can still continue
}
err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int));
if (err != 0) {
this->status_set_warning(LOG_STR("Socket unable to set broadcast"));
}
}
int enable = 1;
auto err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
if (err != 0) {
this->status_set_warning(LOG_STR("Socket unable to set reuseaddr"));
// we can still continue
}
err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int));
if (err != 0) {
this->status_set_warning(LOG_STR("Socket unable to set broadcast"));
#ifdef USE_UDP_IPV6_SEND
if (this->ipv6_send_) {
this->send_socket_v6_ = socket::socket_udp_send(AF_INET6, IPPROTO_IP);
if (this->send_socket_v6_ == nullptr) {
this->status_set_error(LOG_STR("Could not create IPv6 socket"));
this->mark_failed();
return;
}
// Unicast needs no egress interface; multicast does
if (this->ipv6_multicast_)
this->poll_pending_ |= POLL_MCAST_IF;
}
#endif
}
// create listening socket if we either want to subscribe to providers, or need to listen
// for ping key broadcasts.
if (this->should_listen_) {
this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET, IPPROTO_IP);
struct sockaddr_storage server {};
socklen_t server_len;
if (this->listen_address_ != nullptr) {
server_len =
socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), this->listen_address_, this->listen_port_);
// A multicast join needs a socket of the group's address family
this->listen_socket_ = socket::socket_udp_loop_monitored(server.ss_family, IPPROTO_IP);
this->poll_pending_ |= POLL_JOIN;
#ifdef USE_SOCKET_IPV6
// An IPv6 group binds after the join, which supplies the scope id link-local groups need
if (server.ss_family == AF_INET6)
this->poll_pending_ |= POLL_BIND;
#endif
} else {
#ifdef USE_SOCKET_IPV6
server_len = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->listen_port_);
this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET6, IPPROTO_IP);
// Clear IPV6_V6ONLY so IPv4 datagrams also arrive, as mapped addresses
int v6only = 0;
if (this->listen_socket_ != nullptr &&
this->listen_socket_->setsockopt(IPPROTO_IPV6, IPV6_V6ONLY, &v6only, sizeof(v6only)) != 0) {
ESP_LOGW(TAG, "Failed to clear IPV6_V6ONLY: errno %d", errno);
this->status_set_warning(LOG_STR("IPv4 datagrams may not be received"));
}
#else
// Must stay IPv4: raw lwIP can have USE_NETWORK_IPV6 without IPv6 sockets
server_len = socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), "0.0.0.0", this->listen_port_);
this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET, IPPROTO_IP);
#endif
}
if (this->listen_socket_ == nullptr) {
this->status_set_error(LOG_STR("Could not create socket"));
this->mark_failed();
@@ -64,51 +115,93 @@ void UDPComponent::setup() {
this->status_set_warning(LOG_STR("Socket unable to set reuseaddr"));
// we can still continue
}
struct sockaddr_in server {};
server.sin_family = AF_INET;
server.sin_addr.s_addr = ESPHOME_INADDR_ANY;
server.sin_port = htons(this->listen_port_);
if (this->listen_address_ != nullptr) {
inet_aton(this->listen_address_, &server.sin_addr);
// Joined once the network is up; RP2040 has no interface to join before then
this->set_interval(JOIN_POLL_ID, JOIN_POLL_INTERVAL_MS, [this]() {
if (network::is_connected() && this->join_multicast_())
this->cancel_interval(JOIN_POLL_ID);
});
}
err = this->listen_socket_->bind((struct sockaddr *) &server, sizeof(server));
if (err != 0) {
ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno);
this->status_set_error(LOG_STR("Unable to bind socket"));
this->mark_failed();
return;
if ((this->poll_pending_ & POLL_BIND) == 0) {
err = this->listen_socket_->bind((struct sockaddr *) &server, server_len);
if (err != 0) {
ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno);
this->status_set_error(LOG_STR("Unable to bind socket"));
this->mark_failed();
return;
}
}
}
if (this->poll_pending_ != 0) {
// Joins and the IPv6 egress interface need the network up; RP2040 has no interface before then
this->set_interval(NETWORK_POLL_ID, NETWORK_POLL_INTERVAL_MS, [this]() {
if (network::is_connected() && this->network_poll_())
this->cancel_interval(NETWORK_POLL_ID);
});
}
}
bool UDPComponent::join_multicast_() {
if (socket::join_multicast_group(this->listen_socket_.get(), this->listen_address_)) {
ESP_LOGD(TAG, "Joined multicast %s", this->listen_address_);
if (this->join_warning_) {
this->join_warning_ = false;
this->status_clear_warning();
}
return true;
}
// Log the first failure only; the poll keeps retrying every second
if (!this->join_failed_) {
this->join_failed_ = true;
ESP_LOGW(TAG, "Failed to set IP_ADD_MEMBERSHIP. Error %d", errno);
// One warning bit: only a warning raised here is cleared on success, one from setup() stays
if (!this->status_has_warning()) {
this->status_set_warning(LOG_STR("Failed to set IP_ADD_MEMBERSHIP"));
this->join_warning_ = true;
bool UDPComponent::network_poll_() {
bool ok = true;
if (this->poll_pending_ & POLL_JOIN) {
#ifdef USE_SOCKET_IPV6
uint32_t *if_index = &this->mcast_ifindex_;
#else
uint32_t *if_index = nullptr;
#endif
if (socket::join_multicast_group(this->listen_socket_.get(), this->listen_address_, if_index)) {
ESP_LOGD(TAG, "Joined multicast %s", this->listen_address_);
this->poll_pending_ &= ~POLL_JOIN;
this->poll_failed_ &= ~POLL_JOIN;
} else {
this->set_poll_warning_(POLL_JOIN, LOG_STR("Failed to join multicast group"));
ok = false;
}
}
return false;
#ifdef USE_SOCKET_IPV6
if (ok && (this->poll_pending_ & POLL_BIND)) {
struct sockaddr_storage server {};
socklen_t server_len =
socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), this->listen_address_, this->listen_port_);
// POSIX bind() on a link-local group needs it; lwIP ignores it
reinterpret_cast<sockaddr_in6 *>(&server)->sin6_scope_id = this->mcast_ifindex_;
if (this->listen_socket_->bind((struct sockaddr *) &server, server_len) == 0) {
this->poll_pending_ &= ~POLL_BIND;
this->poll_failed_ &= ~POLL_BIND;
} else {
this->set_poll_warning_(POLL_BIND, LOG_STR("Unable to bind socket"));
ok = false;
}
}
#endif
#ifdef USE_UDP_IPV6_SEND
// Only an IPv6 group's join supplies the index, so a failing IPv4 join must not hold back sends
const bool index_pending = (this->poll_pending_ & (POLL_JOIN | POLL_BIND)) == (POLL_JOIN | POLL_BIND);
if ((this->poll_pending_ & POLL_MCAST_IF) && !index_pending) {
// Index 0 picks the first eligible interface
if (socket::set_ipv6_multicast_if(this->send_socket_v6_.get(), this->mcast_ifindex_)) {
this->poll_pending_ &= ~POLL_MCAST_IF;
this->poll_failed_ &= ~POLL_MCAST_IF;
} else {
this->set_poll_warning_(POLL_MCAST_IF, LOG_STR("Failed to set IPv6 multicast interface"));
ok = false;
}
}
#endif
if (!ok)
return false;
if (this->poll_warning_) {
this->poll_warning_ = false;
this->status_clear_warning();
}
return true;
}
void UDPComponent::set_poll_warning_(PollStep step, const LogString *what) {
// Log the first failure of each step only; the poll keeps retrying every second
if (this->poll_failed_ & step)
return;
this->poll_failed_ |= step;
ESP_LOGW(TAG, "%s. Error %d", LOG_STR_ARG(what), errno);
// One warning bit: only a warning raised here is cleared on success, one from setup() stays
if (!this->status_has_warning()) {
this->status_set_warning(what);
this->poll_warning_ = true;
}
}
void UDPComponent::loop() {
@@ -147,8 +240,23 @@ void UDPComponent::dump_config() {
}
void UDPComponent::send_packet(const uint8_t *data, size_t size) {
for (const auto &saddr : this->sockaddrs_) {
auto result = this->broadcast_socket_->sendto(data, size, 0, &saddr, sizeof(saddr));
if (this->is_failed())
return; // a send socket may be missing
for (const auto &entry : this->sockaddrs_) {
#ifdef USE_UDP_IPV6_SEND
const bool v6 = entry.sa.sa_family == AF_INET6;
// Until the egress interface is set, a multicast packet would leave on the wrong one
if (v6 && (this->poll_pending_ & POLL_MCAST_IF) && entry.in6.sin6_addr.s6_addr[0] == 0xff) {
ESP_LOGV(TAG, "Egress interface not set yet, multicast packet dropped");
continue;
}
socket::UDPSendSocket *sock = v6 ? this->send_socket_v6_.get() : this->broadcast_socket_.get();
socklen_t sockaddr_size = v6 ? sizeof(sockaddr_in6) : sizeof(sockaddr_in);
#else
socket::UDPSendSocket *sock = this->broadcast_socket_.get();
socklen_t sockaddr_size = sizeof(sockaddr_in);
#endif
auto result = sock->sendto(data, size, 0, &entry.sa, sockaddr_size);
if (result < 0) {
ESP_LOGW(TAG, "sendto() error %d", errno);
}
+41 -11
View File
@@ -22,6 +22,15 @@ class UDPComponent final : 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; }
#ifdef USE_UDP_IPV6_SEND
/// This instance has IPv6 destinations; multicast ones also need an egress interface.
void set_ipv6_send(bool multicast) {
this->ipv6_send_ = true;
this->ipv6_multicast_ = multicast;
}
/// An instance whose destinations are all IPv6 needs no IPv4 send socket
void set_ipv4_send(bool ipv4_send) { this->ipv4_send_ = ipv4_send; }
#endif
template<typename F> void add_listener(F &&listener) { this->packet_listeners_.add(std::forward<F>(listener)); }
void setup() override;
void loop() override;
@@ -31,22 +40,43 @@ class UDPComponent final : public Component {
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; };
protected:
bool join_multicast_();
enum PollStep : uint8_t { POLL_JOIN = 1, POLL_BIND = 2, POLL_MCAST_IF = 4 };
union SockaddrEntry {
sockaddr sa;
#ifdef USE_UDP_IPV6_SEND
sockaddr_in6 in6; // sizes the storage for IPv6 destinations
#endif
};
/// One pass of the network poll; true once nothing is left to do.
bool network_poll_();
void set_poll_warning_(PollStep step, const LogString *what);
CallbackManager<void(std::span<const uint8_t>)> packet_listeners_{};
std::unique_ptr<socket::UDPSendSocket> broadcast_socket_ = nullptr;
std::unique_ptr<socket::UDPSocket> listen_socket_ = nullptr;
#ifdef USE_UDP_IPV6_SEND
std::unique_ptr<socket::UDPSendSocket> send_socket_v6_ = nullptr;
#endif
FixedVector<SockaddrEntry> sockaddrs_{};
const char *const *addresses_{nullptr};
const char *listen_address_{nullptr};
#ifdef USE_SOCKET_IPV6
uint32_t mcast_ifindex_{0};
#endif
uint16_t listen_port_{};
uint16_t broadcast_port_{};
bool should_broadcast_{};
bool should_listen_{};
bool join_failed_{};
bool join_warning_{};
CallbackManager<void(std::span<const uint8_t>)> packet_listeners_{};
std::unique_ptr<socket::UDPSendSocket> broadcast_socket_ = nullptr;
std::unique_ptr<socket::UDPSocket> listen_socket_ = nullptr;
std::vector<struct sockaddr> sockaddrs_{};
const char *const *addresses_{nullptr};
const char *listen_address_{nullptr};
uint8_t poll_pending_{}; // PollStep bits
uint8_t poll_failed_{}; // PollStep bits already logged
bool poll_warning_{};
#ifdef USE_UDP_IPV6_SEND
bool ipv4_send_{true};
bool ipv6_send_{};
bool ipv6_multicast_{};
#endif
};
} // namespace esphome::udp
@@ -0,0 +1,135 @@
"""Tests for the udp address validators."""
from ipaddress import ip_address
import pytest
from esphome.components.udp import (
CONF_ADDRESSES,
CONF_LISTEN_ADDRESS,
_address,
_final_validate,
_listen_address,
)
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_IPV6, PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
@pytest.mark.parametrize("value", ["239.0.60.53", "255.255.255.255", "ff12::cafe"])
def test_listen_address_accepts_multicast_and_broadcast(value: str) -> None:
assert str(_listen_address(value)) == value
@pytest.mark.parametrize(
("value", "message"),
[
# An IPv4 address must report the IPv4 error, not an IPv6 parse failure
("192.168.1.10", "not a multicast address nor local broadcast address"),
("fe80::1", "not an IPv6 multicast address"),
("ff02::cafe%eth0", "carries a scope id"),
("gg::1", "not a valid IPv6 address"),
],
)
def test_listen_address_reports_matching_family_error(value: str, message: str) -> None:
with pytest.raises(cv.Invalid, match=message):
_listen_address(value)
@pytest.mark.parametrize(
"value", ["10.0.0.1", "255.255.255.255", "ff12::cafe", "2001:db8::1"]
)
def test_address_accepts_routable_destinations(value: str) -> None:
assert str(_address(value)) == value
@pytest.mark.parametrize(
("value", "message"),
[
# The device parses addresses with inet_pton, which takes no scope suffix
("2001:db8::1%wlan0", "carries a scope id"),
# No scope id is set on the destination, so a link-local send would fail
("fe80::1", "is link-local"),
],
)
def test_address_rejects_unsendable_destinations(value: str, message: str) -> None:
with pytest.raises(cv.Invalid, match=message):
_address(value)
_RAW_LWIP_IPV6 = {
"network": {CONF_ENABLE_IPV6: True},
"socket": {"implementation": "lwip_tcp"},
}
_NO_IPV6 = "requires 'enable_ipv6: true'"
_RAW_LWIP = "not supported on this platform"
@pytest.mark.parametrize(
("addresses", "listen_address", "platform", "full_config", "message"),
[
pytest.param(
["ff12::cafe"],
"255.255.255.255",
PlatformFramework.ESP32_IDF,
{},
_NO_IPV6,
id="ipv6_destination_without_ipv6",
),
pytest.param(
["255.255.255.255"],
"ff12::cafe",
PlatformFramework.ESP32_IDF,
{},
_NO_IPV6,
id="ipv6_listen_without_ipv6",
),
pytest.param(
["ff12::cafe"],
"255.255.255.255",
PlatformFramework.ESP8266_ARDUINO,
_RAW_LWIP_IPV6,
_RAW_LWIP,
id="ipv6_destination_on_raw_lwip",
),
pytest.param(
["255.255.255.255"],
"ff12::cafe",
PlatformFramework.ESP8266_ARDUINO,
_RAW_LWIP_IPV6,
_RAW_LWIP,
id="ipv6_listen_on_raw_lwip",
),
],
)
def test_final_validate_rejects_unsupported_ipv6(
set_core_config: SetCoreConfigCallable,
addresses: list[str],
listen_address: str,
platform: PlatformFramework,
full_config: dict,
message: str,
) -> None:
set_core_config(platform, full_config=full_config)
with pytest.raises(cv.Invalid, match=message):
_final_validate(
{
CONF_ADDRESSES: [ip_address(a) for a in addresses],
CONF_LISTEN_ADDRESS: ip_address(listen_address),
}
)
def test_final_validate_accepts_ipv6_when_enabled(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={"network": {CONF_ENABLE_IPV6: True}},
)
_final_validate(
{
CONF_ADDRESSES: [ip_address("ff12::cafe")],
CONF_LISTEN_ADDRESS: ip_address("ff12::cafe"),
}
)
+20
View File
@@ -0,0 +1,20 @@
network:
enable_ipv6: true
udp:
- id: my_udp_v6
listen_address: ff12::cafe
addresses:
- "ff12::cafe"
- "2001:db8::1"
on_receive:
- udp.write:
id: my_udp_v6
data: "pong"
# Default listen address: a dual-stack socket on IPv6 builds
- id: my_udp_dual_stack
port: 18512
on_receive:
- logger.log:
format: "Received %zu bytes"
args: [data.size()]
+30 -26
View File
@@ -3,30 +3,30 @@ wifi:
password: password1
udp:
id: my_udp
listen_address: 239.0.60.53
addresses:
- "239.0.60.53"
- "192.168.1.255"
- "10.0.0.255"
on_receive:
- logger.log:
format: "Received %zu bytes"
args: [data.size()]
- udp.write:
id: my_udp
data: "hello world"
- udp.write:
id: my_udp
data: !lambda |-
return std::vector<uint8_t>{1,3,4,5,6};
- udp.write:
id: my_udp
data: [0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]
- udp.write:
id: my_udp
data: !lambda |-
return std::vector<uint8_t>(data.begin(), data.end());
- id: my_udp
listen_address: 239.0.60.53
addresses:
- "239.0.60.53"
- "192.168.1.255"
- "10.0.0.255"
on_receive:
- logger.log:
format: "Received %zu bytes"
args: [data.size()]
- udp.write:
id: my_udp
data: "hello world"
- udp.write:
id: my_udp
data: !lambda |-
return std::vector<uint8_t>{1,3,4,5,6};
- udp.write:
id: my_udp
data: [0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]
- udp.write:
id: my_udp
data: !lambda |-
return std::vector<uint8_t>(data.begin(), data.end());
number:
- platform: template
@@ -43,8 +43,12 @@ button:
on_press:
then:
- udp.write:
id: my_udp
data: [0x01, 0x02, 0x03]
- udp.write:
id: my_udp
data: [0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]
- udp.write: !lambda |-
return {0x10, 0x20, (uint8_t)id(udp_my_number).state};
- udp.write:
id: my_udp
data: !lambda |-
return {0x10, 0x20, (uint8_t)id(udp_my_number).state};
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
udp: !include common.yaml
udp_ipv6: !include common-ipv6.yaml
+5
View File
@@ -13,3 +13,8 @@ udp:
id: my_udp
data: !lambda |-
return std::vector<uint8_t>{1,3,4,5,6};
# Short forms without id: valid while there is a single udp instance
- udp.write:
data: [0x01, 0x02, 0x03]
- udp.write: !lambda |-
return std::vector<uint8_t>(data.begin(), data.end());
@@ -0,0 +1,5 @@
packages:
udp_ipv6: !include common-ipv6.yaml
openthread:
tlv: 0E080000000000010000
@@ -0,0 +1,24 @@
esphome:
name: udp-ipv6-unicast-test
host:
api:
network:
enable_ipv6: true
logger:
level: DEBUG
udp:
- id: test_udp
port:
listen_port: UDP_LISTEN_PORT_PLACEHOLDER
broadcast_port: UDP_REPLY_PORT_PLACEHOLDER
addresses:
- "::1"
on_receive:
- udp.write:
id: test_udp
data: "PONG"
+63
View File
@@ -0,0 +1,63 @@
"""Integration test for UDP over IPv6 with the default dual-stack listen socket."""
from __future__ import annotations
import asyncio
import socket
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
def _free_port(family: socket.AddressFamily, host: str) -> int:
with socket.socket(family, socket.SOCK_DGRAM) as sock:
sock.bind((host, 0))
return sock.getsockname()[1]
@pytest.mark.asyncio
async def test_udp_ipv6_unicast(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""IPv6 and IPv4 datagrams both reach the listen socket, and replies go out over IPv6."""
if not socket.has_ipv6:
pytest.skip("Host has no IPv6 support")
try:
reply_sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
reply_sock.bind(("::1", 0))
except OSError:
pytest.skip("Host has no IPv6 loopback")
reply_sock.setblocking(False)
reply_port = reply_sock.getsockname()[1]
listen_port = _free_port(socket.AF_INET6, "::")
config = yaml_config.replace(
"UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port)
).replace("UDP_REPLY_PORT_PLACEHOLDER", str(reply_port))
loop = asyncio.get_running_loop()
try:
async with (
run_compiled(config),
api_client_connected() as client,
):
assert await client.device_info() is not None
for family, host in (
(socket.AF_INET6, "::1"),
# An IPv4 sender reaches the IPv6 socket as a mapped address
(socket.AF_INET, "127.0.0.1"),
):
with socket.socket(family, socket.SOCK_DGRAM) as send_sock:
send_sock.sendto(b"PING", (host, listen_port))
try:
data = await asyncio.wait_for(
loop.sock_recv(reply_sock, 64), timeout=5.0
)
except TimeoutError:
pytest.fail(f"No reply over IPv6 to a PING sent to {host}")
assert data == b"PONG"
finally:
reply_sock.close()