mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 10:12:52 +00:00
[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:
co-authored by
J. Nick Koston
Claude Opus 5.5
J. Nick Koston
parent
ffa849753a
commit
0b6856c052
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
)
|
||||
@@ -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()]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user