async_tcp: Add AsyncClient for ESP-IDF and host (#12337)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
David Woodhouse
2026-01-06 00:37:38 +01:00
committed by GitHub
parent c8f5a97cef
commit 94bedd83be
5 changed files with 287 additions and 22 deletions

View File

@@ -1,37 +1,50 @@
# Dummy integration to allow relying on AsyncTCP
# Async TCP client support for all platforms
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_LN882X,
PLATFORM_RTL87XX,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
CODEOWNERS = ["@esphome/core"]
DEPENDENCIES = ["network"]
CONFIG_SCHEMA = cv.All(
cv.Schema({}),
cv.only_with_arduino,
cv.only_on(
[
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_BK72XX,
PLATFORM_LN882X,
PLATFORM_RTL87XX,
]
),
)
def AUTO_LOAD() -> list[str]:
# Socket component needed for platforms using socket-based implementation
# ESP32, ESP8266, RP2040, and LibreTiny use AsyncTCP libraries, others use sockets
if (
not CORE.is_esp32
and not CORE.is_esp8266
and not CORE.is_rp2040
and not CORE.is_libretiny
):
return ["socket"]
return []
# Support all platforms - Arduino/ESP-IDF get libraries, other platforms use socket implementation
CONFIG_SCHEMA = cv.Schema({})
@coroutine_with_priority(CoroPriority.NETWORK_TRANSPORT)
async def to_code(config):
if CORE.is_esp32 or CORE.is_libretiny:
if CORE.using_esp_idf:
# ESP-IDF needs the IDF component
from esphome.components.esp32 import add_idf_component
add_idf_component(name="esp32async/asynctcp", ref="3.4.91")
elif CORE.is_esp32 or CORE.is_libretiny:
# https://github.com/ESP32Async/AsyncTCP
cg.add_library("ESP32Async/AsyncTCP", "3.4.5")
elif CORE.is_esp8266:
# https://github.com/ESP32Async/ESPAsyncTCP
cg.add_library("ESP32Async/ESPAsyncTCP", "2.0.0")
elif CORE.is_rp2040:
# https://github.com/khoih-prog/AsyncTCP_RP2040W
cg.add_library("khoih-prog/AsyncTCP_RP2040W", "1.2.0")
# Other platforms (host, etc) use socket-based implementation
def FILTER_SOURCE_FILES() -> list[str]:
# Exclude socket implementation for platforms that use AsyncTCP libraries
if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny:
return ["async_tcp_socket.cpp"]
return []

View File

@@ -0,0 +1,17 @@
#pragma once
#include "esphome/core/defines.h"
#if (defined(USE_ESP32) || defined(USE_LIBRETINY)) && !defined(CLANG_TIDY)
// Use AsyncTCP library for ESP32 (Arduino or ESP-IDF) and LibreTiny
// But not for clang-tidy as the header file isn't present in that case
#include <AsyncTCP.h>
#elif defined(USE_ESP8266)
// Use ESPAsyncTCP library for ESP8266 (always Arduino)
#include <ESPAsyncTCP.h>
#elif defined(USE_RP2040)
// Use AsyncTCP_RP2040W library for RP2040
#include <AsyncTCP_RP2040W.h>
#else
// Use socket-based implementation for other platforms and clang-tidy
#include "async_tcp_socket.h"
#endif

View File

@@ -0,0 +1,161 @@
#include "async_tcp_socket.h"
#if defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)
#include "esphome/components/network/util.h"
#include "esphome/core/log.h"
#include <cerrno>
#include <sys/select.h>
namespace esphome::async_tcp {
static const char *const TAG = "async_tcp";
// Read buffer size matches TCP MSS (1500 MTU - 40 bytes IP/TCP headers).
// This implementation only runs on ESP-IDF and host which have ample stack.
static constexpr size_t READ_BUFFER_SIZE = 1460;
bool AsyncClient::connect(const char *host, uint16_t port) {
if (connected_ || connecting_) {
ESP_LOGW(TAG, "Already connected/connecting");
return false;
}
// Resolve address
struct sockaddr_storage addr;
socklen_t addrlen = esphome::socket::set_sockaddr((struct sockaddr *) &addr, sizeof(addr), host, port);
if (addrlen == 0) {
ESP_LOGE(TAG, "Invalid address: %s", host);
if (error_cb_)
error_cb_(error_arg_, this, -1);
return false;
}
// Create socket with loop monitoring
int family = ((struct sockaddr *) &addr)->sa_family;
socket_ = esphome::socket::socket_loop_monitored(family, SOCK_STREAM, IPPROTO_TCP);
if (!socket_) {
ESP_LOGE(TAG, "Failed to create socket");
if (error_cb_)
error_cb_(error_arg_, this, -1);
return false;
}
socket_->setblocking(false);
int err = socket_->connect((struct sockaddr *) &addr, addrlen);
if (err == 0) {
// Connection succeeded immediately (rare, but possible for localhost)
connected_ = true;
if (connect_cb_)
connect_cb_(connect_arg_, this);
return true;
}
if (errno != EINPROGRESS) {
ESP_LOGE(TAG, "Connect failed: %d", errno);
close();
if (error_cb_)
error_cb_(error_arg_, this, errno);
return false;
}
connecting_ = true;
return true;
}
void AsyncClient::close() {
socket_.reset();
bool was_connected = connected_;
connected_ = false;
connecting_ = false;
if (was_connected && disconnect_cb_)
disconnect_cb_(disconnect_arg_, this);
}
size_t AsyncClient::write(const char *data, size_t len) {
if (!socket_ || !connected_)
return 0;
ssize_t sent = socket_->write(data, len);
if (sent < 0) {
if (errno != EAGAIN && errno != EWOULDBLOCK) {
ESP_LOGE(TAG, "Write error: %d", errno);
close();
if (error_cb_)
error_cb_(error_arg_, this, errno);
}
return 0;
}
return sent;
}
void AsyncClient::loop() {
if (!socket_)
return;
if (connecting_) {
// For connecting, we need to check writability, not readability
// The Application's select() only monitors read FDs, so we do our own check here
// For ESP platforms lwip_select() might be faster, but this code isn't used
// on those platforms anyway. If it was, we'd fix the Application select()
// to report writability instead of doing it this way.
int fd = socket_->get_fd();
if (fd < 0) {
ESP_LOGW(TAG, "Invalid socket fd");
close();
return;
}
fd_set writefds;
FD_ZERO(&writefds);
FD_SET(fd, &writefds);
struct timeval tv = {0, 0};
int ret = select(fd + 1, nullptr, &writefds, nullptr, &tv);
if (ret > 0 && FD_ISSET(fd, &writefds)) {
int error = 0;
socklen_t len = sizeof(error);
if (socket_->getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) == 0 && error == 0) {
connecting_ = false;
connected_ = true;
if (connect_cb_)
connect_cb_(connect_arg_, this);
} else {
ESP_LOGW(TAG, "Connection failed: %d", error);
close();
if (error_cb_)
error_cb_(error_arg_, this, error);
}
} else if (ret < 0) {
ESP_LOGE(TAG, "Select error: %d", errno);
close();
if (error_cb_)
error_cb_(error_arg_, this, errno);
}
} else if (connected_) {
// For connected sockets, use the Application's select() results
if (!socket_->ready())
return;
uint8_t buf[READ_BUFFER_SIZE];
ssize_t len = socket_->read(buf, READ_BUFFER_SIZE);
if (len == 0) {
ESP_LOGI(TAG, "Connection closed by peer");
close();
} else if (len > 0) {
if (data_cb_)
data_cb_(data_arg_, this, buf, len);
} else if (errno != EAGAIN && errno != EWOULDBLOCK) {
ESP_LOGW(TAG, "Read error: %d", errno);
close();
if (error_cb_)
error_cb_(error_arg_, this, errno);
}
}
}
} // namespace esphome::async_tcp
#endif // defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)

View File

@@ -0,0 +1,73 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)
#include "esphome/components/socket/socket.h"
#include <functional>
#include <memory>
#include <string>
#include <utility>
namespace esphome::async_tcp {
/// AsyncClient API for platforms using sockets (ESP-IDF, host, etc.)
/// NOTE: This class is NOT thread-safe. All methods must be called from the main loop.
class AsyncClient {
public:
using AcConnectHandler = std::function<void(void *, AsyncClient *)>;
using AcDataHandler = std::function<void(void *, AsyncClient *, void *data, size_t len)>;
using AcErrorHandler = std::function<void(void *, AsyncClient *, int8_t error)>;
AsyncClient() = default;
~AsyncClient() = default;
[[nodiscard]] bool connect(const char *host, uint16_t port);
void close();
[[nodiscard]] bool connected() const { return connected_; }
size_t write(const char *data, size_t len);
void onConnect(AcConnectHandler cb, void *arg = nullptr) { // NOLINT(readability-identifier-naming)
connect_cb_ = std::move(cb);
connect_arg_ = arg;
}
void onDisconnect(AcConnectHandler cb, void *arg = nullptr) { // NOLINT(readability-identifier-naming)
disconnect_cb_ = std::move(cb);
disconnect_arg_ = arg;
}
/// Set data callback. NOTE: data pointer is only valid during callback execution.
void onData(AcDataHandler cb, void *arg = nullptr) { // NOLINT(readability-identifier-naming)
data_cb_ = std::move(cb);
data_arg_ = arg;
}
void onError(AcErrorHandler cb, void *arg = nullptr) { // NOLINT(readability-identifier-naming)
error_cb_ = std::move(cb);
error_arg_ = arg;
}
// Must be called from loop()
void loop();
private:
std::unique_ptr<esphome::socket::Socket> socket_;
AcConnectHandler connect_cb_{nullptr};
void *connect_arg_{nullptr};
AcConnectHandler disconnect_cb_{nullptr};
void *disconnect_arg_{nullptr};
AcDataHandler data_cb_{nullptr};
void *data_arg_{nullptr};
AcErrorHandler error_cb_{nullptr};
void *error_arg_{nullptr};
bool connected_{false};
bool connecting_{false};
};
} // namespace esphome::async_tcp
// Expose AsyncClient in global namespace to match library behavior
using esphome::async_tcp::AsyncClient; // NOLINT(google-global-names-in-headers)
#define ESPHOME_ASYNC_TCP_SOCKET_IMPL
#endif // defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)

View File

@@ -580,6 +580,7 @@ def lint_relative_py_import(fname: Path, line, col, content):
],
exclude=[
"esphome/components/socket/headers.h",
"esphome/components/async_tcp/async_tcp.h",
"esphome/components/esp32/core.cpp",
"esphome/components/esp8266/core.cpp",
"esphome/components/rp2040/core.cpp",