mirror of
https://github.com/esphome/esphome.git
synced 2026-10-04 18:11:29 +00:00
[socket] Add a reconnecting TCP client link (#19999)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
co-authored by
J. Nick Koston
parent
99070187b8
commit
b324a7c6e0
+35
@@ -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]))
|
||||
+28
@@ -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
|
||||
+24
@@ -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()
|
||||
Reference in New Issue
Block a user