[socket] Add a reconnecting TCP client link (#19999)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Bascht74
2026-10-01 23:40:09 +00:00
committed by GitHub
co-authored by J. Nick Koston
parent 99070187b8
commit b324a7c6e0
7 changed files with 419 additions and 0 deletions
@@ -0,0 +1,35 @@
import esphome.codegen as cg
from esphome.components.const import CONF_HOST
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_PORT
from esphome.types import ConfigType
AUTO_LOAD = ["socket"]
CONF_RECONNECT_INTERVAL = "reconnect_interval"
tcp_client_link_test_component_ns = cg.esphome_ns.namespace(
"tcp_client_link_test_component"
)
TcpClientLinkTestComponent = tcp_client_link_test_component_ns.class_(
"TcpClientLinkTestComponent", cg.Component
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(TcpClientLinkTestComponent),
cv.Required(CONF_HOST): cv.string,
cv.Required(CONF_PORT): cv.port,
cv.Optional(
CONF_RECONNECT_INTERVAL, default="1s"
): cv.positive_time_period_milliseconds,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_host(config[CONF_HOST]))
cg.add(var.set_port(config[CONF_PORT]))
cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL]))
@@ -0,0 +1,28 @@
#include "tcp_client_link_test_component.h"
#include "esphome/core/log.h"
namespace esphome::tcp_client_link_test_component {
static const char *const TAG = "tcp_link_test";
void TcpClientLinkTestComponent::setup() { this->link_.begin(TAG); }
void TcpClientLinkTestComponent::loop() {
this->link_.poll();
bool up = this->link_.connected();
if (up != this->was_up_) {
this->was_up_ = up;
ESP_LOGI(TAG, "Link %s", up ? LOG_STR_LITERAL("up") : LOG_STR_LITERAL("down"));
}
if (!up || !this->link_.ready()) {
return;
}
uint8_t buf[64];
ssize_t count = this->link_.read(buf, sizeof(buf));
if (count > 0) {
ESP_LOGI(TAG, "Echoing %d bytes", static_cast<int>(count));
this->link_.write(buf, static_cast<size_t>(count));
}
}
} // namespace esphome::tcp_client_link_test_component
@@ -0,0 +1,24 @@
#pragma once
#include "esphome/components/socket/tcp_client_link.h"
#include "esphome/core/component.h"
namespace esphome::tcp_client_link_test_component {
/// Echoes every byte the link receives back to the peer and logs link edges.
class TcpClientLinkTestComponent : public Component {
public:
void set_host(const char *host) { this->link_.set_host(host); }
void set_port(uint16_t port) { this->link_.set_port(port); }
void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); }
void setup() override;
void loop() override;
void on_shutdown() override { this->link_.close(); }
protected:
socket::TcpClientLink link_;
bool was_up_{false};
};
} // namespace esphome::tcp_client_link_test_component
@@ -0,0 +1,20 @@
esphome:
name: socket-tcp-client-link-test
host:
api:
logger:
level: INFO
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
components: [tcp_client_link_test_component]
tcp_client_link_test_component:
host: 127.0.0.1
port: 18123
reconnect_interval: 1s
@@ -0,0 +1,88 @@
"""Integration test for socket::TcpClientLink on host.
Pytest runs a real TCP server; the device echoes through the link.
Covers connect, read, write, a server-initiated drop and the reconnect.
"""
from __future__ import annotations
import asyncio
import contextlib
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
PAYLOAD = b"hello link"
@pytest.mark.asyncio
async def test_socket_tcp_client_link(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
unused_tcp_port_factory,
) -> None:
server_port = unused_tcp_port_factory()
yaml_config = yaml_config.replace("port: 18123", f"port: {server_port}")
echoed: list[bytes] = []
echo_done = asyncio.Event()
reconnected = asyncio.Event()
link_down = asyncio.Event()
second_link_up = asyncio.Event()
link_up_count = 0
def on_log_line(line: str) -> None:
nonlocal link_up_count
if "Link up" in line:
link_up_count += 1
if link_up_count >= 2:
second_link_up.set()
elif "Link down" in line:
link_down.set()
async def handle(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
if not echo_done.is_set():
writer.write(PAYLOAD)
await writer.drain()
with contextlib.suppress(TimeoutError, asyncio.IncompleteReadError):
echoed.append(
await asyncio.wait_for(reader.readexactly(len(PAYLOAD)), 10)
)
echo_done.set()
# Drop the connection so the link has to reconnect.
writer.close()
return
reconnected.set()
server = await asyncio.start_server(handle, "127.0.0.1", server_port)
try:
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "socket-tcp-client-link-test"
try:
await asyncio.wait_for(echo_done.wait(), timeout=15.0)
except TimeoutError:
pytest.fail("Link never connected or echoed")
assert echoed and echoed[0] == PAYLOAD, "Echo payload mismatch"
try:
await asyncio.wait_for(link_down.wait(), timeout=15.0)
except TimeoutError:
pytest.fail("Link never reported the drop")
try:
await asyncio.wait_for(reconnected.wait(), timeout=15.0)
await asyncio.wait_for(second_link_up.wait(), timeout=15.0)
except TimeoutError:
pytest.fail("Link did not reconnect after the server dropped it")
finally:
server.close()
await server.wait_closed()