mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 11:26:39 +00:00
[uart_tcp] Claim the UART, reject duplicate server ports, pace on loop_interval (#20180)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
co-authored by
J. Nick Koston
parent
cb1b4b0bd7
commit
ab5a4b4f04
@@ -9,15 +9,20 @@ from esphome.components.const import (
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DEBUG,
|
||||
CONF_DUMMY_RECEIVER,
|
||||
CONF_ID,
|
||||
CONF_PORT,
|
||||
CONF_UART_ID,
|
||||
DEVICE_CLASS_CONNECTIVITY,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@Bascht74"]
|
||||
DOMAIN = "uart_tcp"
|
||||
DEPENDENCIES = ["network", "uart"]
|
||||
AUTO_LOAD = ["binary_sensor", "socket"]
|
||||
MULTI_CONF = True
|
||||
@@ -57,6 +62,75 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def _subtree_references_uart(node: object, uart_id: str) -> bool:
|
||||
if isinstance(node, dict):
|
||||
return any(
|
||||
(key == CONF_UART_ID and str(value) == uart_id)
|
||||
or _subtree_references_uart(value, uart_id)
|
||||
for key, value in node.items()
|
||||
)
|
||||
if isinstance(node, list):
|
||||
return any(_subtree_references_uart(item, uart_id) for item in node)
|
||||
return False
|
||||
|
||||
|
||||
def _reject_dummy_receiver(uart_conf: ConfigType) -> ConfigType:
|
||||
debug = uart_conf.get(CONF_DEBUG)
|
||||
if isinstance(debug, dict) and debug.get(CONF_DUMMY_RECEIVER):
|
||||
raise cv.Invalid(
|
||||
"dummy_receiver reads this UART and drops the bytes uart_tcp should forward.",
|
||||
[CONF_DEBUG, CONF_DUMMY_RECEIVER],
|
||||
)
|
||||
return uart_conf
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
# A second reader would split the bytes with this one, and every connect
|
||||
# discards what the other reader has not read yet.
|
||||
full_config = fv.full_config.get()
|
||||
data = full_config.data.setdefault(DOMAIN, {})
|
||||
uart_id = str(config[CONF_UART_ID])
|
||||
used = data.setdefault(CONF_UART_ID, set())
|
||||
if uart_id in used:
|
||||
raise cv.Invalid(
|
||||
f"The UART '{uart_id}' is already used by another 'uart_tcp' entry. "
|
||||
"Each uart_tcp needs its own UART.",
|
||||
[CONF_UART_ID],
|
||||
)
|
||||
used.add(uart_id)
|
||||
# Grouped CI builds share one bus between components, like uart's pin check.
|
||||
# Bare `id:` references (a uart.write action) and lambdas are not caught.
|
||||
if not CORE.testing_mode:
|
||||
for domain, domain_conf in full_config.items():
|
||||
if domain != DOMAIN and _subtree_references_uart(domain_conf, uart_id):
|
||||
raise cv.Invalid(
|
||||
f"The UART '{uart_id}' is also used by '{domain}'. "
|
||||
"uart_tcp requires exclusive use of that UART.",
|
||||
[CONF_UART_ID],
|
||||
)
|
||||
fv.id_declaration_match_schema(_reject_dummy_receiver)(config[CONF_UART_ID])
|
||||
|
||||
if config[CONF_ROLE] == "server":
|
||||
# Two listeners on one port cannot both serve it. Only uart_tcp and
|
||||
# tcp_uart servers are compared here, not other listeners such as api.
|
||||
port = config[CONF_PORT]
|
||||
ports = data.setdefault(CONF_PORT, set())
|
||||
if port in ports or any(
|
||||
conf[CONF_ROLE] == "server" and conf[CONF_PORT] == port
|
||||
for conf in full_config.get("tcp_uart", [])
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"Port {port} is already the listen port of another uart_tcp "
|
||||
"server or of a tcp_uart server.",
|
||||
[CONF_PORT],
|
||||
)
|
||||
ports.add(port)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "uart_tcp.h"
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -10,8 +11,8 @@ namespace esphome::uart_tcp {
|
||||
|
||||
static const char *const TAG = "uart_tcp";
|
||||
|
||||
// Bytes per 16 ms loop pass at 10 bits per byte: baud / 10 / 62.5.
|
||||
static constexpr uint32_t BAUD_PACE_DIVISOR = 625;
|
||||
// Keeps the pacing product in 32 bits up to about 10 Mbaud.
|
||||
static constexpr uint32_t MAX_PACE_SPAN_MS = 4000;
|
||||
|
||||
void UartTcp::setup() {
|
||||
this->link_.begin(TAG);
|
||||
@@ -61,9 +62,13 @@ void UartTcp::read_socket_() {
|
||||
// not fit in the socket, so TCP flow control throttles the peer.
|
||||
size_t room = this->parent_->available_for_write();
|
||||
if (room == SIZE_MAX) {
|
||||
// Capacity unknown on this platform; pace to one loop pass of UART time
|
||||
// (16 ms at 10 bits per byte) so a blocking write stays short.
|
||||
room = std::max<size_t>(1, this->parent_->get_baud_rate() / BAUD_PACE_DIVISOR);
|
||||
// Capacity unknown on this platform; pace to the UART time since the last write,
|
||||
// at most one loop interval and 4 s, so a pass woken early by the socket writes little.
|
||||
uint32_t span = std::min(
|
||||
{App.get_loop_component_start_time() - this->last_write_ms_, App.get_loop_interval(), MAX_PACE_SPAN_MS});
|
||||
// 10 bits per byte on the line.
|
||||
uint32_t paced = this->parent_->get_baud_rate() / 10 * span / 1000;
|
||||
room = std::max<size_t>(1, paced);
|
||||
}
|
||||
if (room == 0) {
|
||||
this->rx_pending_ = true;
|
||||
@@ -81,11 +86,12 @@ void UartTcp::read_socket_() {
|
||||
}
|
||||
this->rx_pending_ = static_cast<size_t>(count) == want;
|
||||
this->write_array(tmp, static_cast<size_t>(count));
|
||||
this->last_write_ms_ = App.get_loop_component_start_time();
|
||||
}
|
||||
|
||||
void UartTcp::discard_uart_() {
|
||||
// Drain exactly what was buffered while the link was down; later bytes are live.
|
||||
uint8_t dump[32];
|
||||
uint8_t dump[DISCARD_CHUNK];
|
||||
size_t left = this->available();
|
||||
while (left != 0) {
|
||||
size_t n = std::min(left, sizeof(dump));
|
||||
|
||||
@@ -40,12 +40,16 @@ class UartTcp final : public Component, public uart::UARTDevice {
|
||||
void discard_uart_();
|
||||
|
||||
static constexpr size_t READ_CHUNK = 128;
|
||||
// Scratch size for dropping stale UART bytes on connect.
|
||||
static constexpr size_t DISCARD_CHUNK = 32;
|
||||
|
||||
socket::TcpClientLink link_;
|
||||
#ifdef USE_SOCKET_TCP_LISTENER
|
||||
socket::TcpListener listener_;
|
||||
#endif
|
||||
binary_sensor::BinarySensor *connected_sensor_{nullptr};
|
||||
// Loop start time of the last socket-to-UART write; sizes the next paced write.
|
||||
uint32_t last_write_ms_{0};
|
||||
bool server_{false};
|
||||
// The link state loop() saw last; edges clear the buffer and publish the sensor.
|
||||
bool link_was_up_{false};
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for the uart_tcp final validation: one reader per UART, one server per port."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import uart_tcp
|
||||
from esphome.components.const import CONF_ROLE
|
||||
from esphome.config import Config
|
||||
from esphome.const import (
|
||||
CONF_DEBUG,
|
||||
CONF_DUMMY_RECEIVER,
|
||||
CONF_ID,
|
||||
CONF_PORT,
|
||||
CONF_UART_ID,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
_final_validate = uart_tcp._final_validate
|
||||
|
||||
|
||||
def _full_config(uarts: list[ConfigType] | None = None, **domains) -> Config:
|
||||
"""A full config declaring uart_0 and uart_1, as the ID pass leaves it."""
|
||||
uarts = uarts or [{CONF_ID: ID("uart_0")}, {CONF_ID: ID("uart_1")}]
|
||||
full = Config()
|
||||
full["uart"] = uarts
|
||||
for index, uart_conf in enumerate(uarts):
|
||||
full.declare_ids.append((uart_conf[CONF_ID], ["uart", index, CONF_ID]))
|
||||
full.update(domains)
|
||||
return full
|
||||
|
||||
|
||||
def _entry(uart_id: str, port: int = 8899, role: str = "server") -> ConfigType:
|
||||
return {CONF_UART_ID: ID(uart_id), CONF_PORT: port, CONF_ROLE: role}
|
||||
|
||||
|
||||
def _set(set_core_config: SetCoreConfigCallable, full: Config) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF, full_config=full)
|
||||
|
||||
|
||||
def test_accepts_entries_on_distinct_uarts_and_ports(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_set(set_core_config, _full_config())
|
||||
_final_validate(_entry("uart_0", 8899))
|
||||
_final_validate(_entry("uart_1", 8900))
|
||||
|
||||
|
||||
def test_rejects_two_entries_on_one_uart(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_set(set_core_config, _full_config())
|
||||
_final_validate(_entry("uart_0", 8899))
|
||||
with pytest.raises(cv.Invalid, match="already used by another 'uart_tcp'"):
|
||||
_final_validate(_entry("uart_0", 8900))
|
||||
|
||||
|
||||
def test_rejects_uart_shared_with_another_component(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_set(
|
||||
set_core_config,
|
||||
_full_config(modbus=[{CONF_ID: ID("hub"), CONF_UART_ID: ID("uart_0")}]),
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match="also used by 'modbus'"):
|
||||
_final_validate(_entry("uart_0"))
|
||||
|
||||
|
||||
def test_ignores_other_components_on_other_uarts(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_set(
|
||||
set_core_config,
|
||||
_full_config(
|
||||
modbus=[{CONF_ID: ID("hub"), CONF_UART_ID: ID("uart_1")}],
|
||||
uart_tcp=[_entry("uart_0")],
|
||||
),
|
||||
)
|
||||
_final_validate(_entry("uart_0"))
|
||||
|
||||
|
||||
def test_testing_mode_allows_a_shared_bus(
|
||||
set_core_config: SetCoreConfigCallable, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Grouped CI builds put several components on one bus package.
|
||||
monkeypatch.setattr(CORE, "testing_mode", True)
|
||||
_set(
|
||||
set_core_config,
|
||||
_full_config(modbus=[{CONF_ID: ID("hub"), CONF_UART_ID: ID("uart_0")}]),
|
||||
)
|
||||
_final_validate(_entry("uart_0"))
|
||||
|
||||
|
||||
def test_rejects_dummy_receiver(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_set(
|
||||
set_core_config,
|
||||
_full_config(
|
||||
uarts=[{CONF_ID: ID("uart_0"), CONF_DEBUG: {CONF_DUMMY_RECEIVER: True}}]
|
||||
),
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match="dummy_receiver"):
|
||||
_final_validate(_entry("uart_0"))
|
||||
|
||||
|
||||
def test_rejects_two_servers_on_one_port(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_set(set_core_config, _full_config())
|
||||
_final_validate(_entry("uart_0", 8899))
|
||||
with pytest.raises(cv.Invalid, match="Port 8899 is already the listen port"):
|
||||
_final_validate(_entry("uart_1", 8899))
|
||||
|
||||
|
||||
def test_rejects_server_on_a_tcp_uart_server_port(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_set(
|
||||
set_core_config,
|
||||
_full_config(
|
||||
tcp_uart=[{CONF_ID: ID("tcp"), CONF_ROLE: "server", CONF_PORT: 502}]
|
||||
),
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match="Port 502 is already the listen port"):
|
||||
_final_validate(_entry("uart_0", 502))
|
||||
|
||||
|
||||
def test_clients_may_share_a_port_number(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
# A client port is the remote end; only listeners collide.
|
||||
_set(
|
||||
set_core_config,
|
||||
_full_config(
|
||||
tcp_uart=[{CONF_ID: ID("tcp"), CONF_ROLE: "client", CONF_PORT: 502}]
|
||||
),
|
||||
)
|
||||
_final_validate(_entry("uart_0", 502))
|
||||
_final_validate(_entry("uart_1", 502, role="client"))
|
||||
@@ -0,0 +1,7 @@
|
||||
# This file's presence makes pytest treat this directory as a package named
|
||||
# "uart_tcp"; required for cpp unit testing.
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
manifest.multi_conf = False
|
||||
@@ -0,0 +1,275 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <cerrno>
|
||||
#include <csignal>
|
||||
#include <cstdint>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/uart_tcp/uart_tcp.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/wake.h"
|
||||
|
||||
#ifdef USE_HOST
|
||||
|
||||
namespace esphome::uart_tcp::testing {
|
||||
|
||||
// A UART the test fills and drains directly; available_for_write() is settable.
|
||||
class FakeUart : public uart::UARTComponent {
|
||||
public:
|
||||
FakeUart() { this->set_baud_rate(9600); }
|
||||
void write_array(const uint8_t *data, size_t len) override {
|
||||
this->tx.insert(this->tx.end(), data, data + len);
|
||||
this->writes.push_back(len);
|
||||
}
|
||||
bool peek_byte(uint8_t *data) override {
|
||||
if (this->rx.empty())
|
||||
return false;
|
||||
*data = this->rx.front();
|
||||
return true;
|
||||
}
|
||||
bool read_array(uint8_t *data, size_t len) override {
|
||||
if (len > this->rx.size())
|
||||
return false;
|
||||
std::copy(this->rx.begin(), this->rx.begin() + len, data);
|
||||
this->rx.erase(this->rx.begin(), this->rx.begin() + len);
|
||||
return true;
|
||||
}
|
||||
size_t available() override { return this->rx.size(); }
|
||||
size_t available_for_write() override { return this->room; }
|
||||
uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; }
|
||||
void check_logger_conflict() override {}
|
||||
|
||||
void feed(const char *text) {
|
||||
for (const char *p = text; *p != '\0'; p++)
|
||||
this->rx.push_back(static_cast<uint8_t>(*p));
|
||||
}
|
||||
|
||||
std::vector<uint8_t> rx;
|
||||
std::vector<uint8_t> tx;
|
||||
std::vector<size_t> writes;
|
||||
size_t room{SIZE_MAX};
|
||||
};
|
||||
|
||||
// Client role against a loopback listener the test owns.
|
||||
class UartTcpClient : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
// EPIPE must come back as an errno, not a signal.
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
App.set_loop_interval(16);
|
||||
this->listen_fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
ASSERT_GE(this->listen_fd_, 0);
|
||||
struct sockaddr_in addr {};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
ASSERT_EQ(::bind(this->listen_fd_, reinterpret_cast<struct sockaddr *>(&addr), sizeof(addr)), 0);
|
||||
ASSERT_EQ(::listen(this->listen_fd_, 1), 0);
|
||||
ASSERT_EQ(::fcntl(this->listen_fd_, F_SETFL, O_NONBLOCK), 0);
|
||||
socklen_t len = sizeof(addr);
|
||||
ASSERT_EQ(::getsockname(this->listen_fd_, reinterpret_cast<struct sockaddr *>(&addr), &len), 0);
|
||||
|
||||
this->bridge_.set_uart_parent(&this->uart_);
|
||||
this->bridge_.set_host("127.0.0.1");
|
||||
this->bridge_.set_port(ntohs(addr.sin_port));
|
||||
// A zero interval lets a dropped link retry on the next pass.
|
||||
this->bridge_.set_reconnect_interval(0);
|
||||
this->bridge_.set_connected_sensor(&this->sensor_);
|
||||
this->tick(0);
|
||||
this->bridge_.setup();
|
||||
}
|
||||
void TearDown() override {
|
||||
this->bridge_.on_shutdown();
|
||||
this->close_peer();
|
||||
::close(this->listen_fd_);
|
||||
App.set_loop_interval(16);
|
||||
}
|
||||
|
||||
// Advance the test clock and publish it as the loop start time, as Application::loop() does.
|
||||
void tick(uint32_t elapsed_ms) {
|
||||
this->now_ += elapsed_ms;
|
||||
LoopBlockingGuard dispatch{nullptr, nullptr, this->now_};
|
||||
}
|
||||
// One main loop pass that started elapsed_ms after the previous one: select()
|
||||
// marks readable sockets, then the component runs.
|
||||
void pass(uint32_t elapsed_ms = 16) {
|
||||
internal::wakeable_delay(5);
|
||||
this->tick(elapsed_ms);
|
||||
this->bridge_.loop();
|
||||
}
|
||||
// Pass until the bridge connected and the test accepted it.
|
||||
void connect() {
|
||||
for (int i = 0; i < 50 && this->peer_fd_ < 0; i++) {
|
||||
this->pass();
|
||||
this->peer_fd_ = ::accept(this->listen_fd_, nullptr, nullptr);
|
||||
}
|
||||
ASSERT_GE(this->peer_fd_, 0);
|
||||
for (int i = 0; i < 50 && !this->sensor_.state; i++)
|
||||
this->pass();
|
||||
ASSERT_TRUE(this->sensor_.state);
|
||||
}
|
||||
void close_peer() {
|
||||
if (this->peer_fd_ >= 0) {
|
||||
::close(this->peer_fd_);
|
||||
this->peer_fd_ = -1;
|
||||
}
|
||||
}
|
||||
void send(size_t count) {
|
||||
std::vector<uint8_t> data(count);
|
||||
for (size_t i = 0; i < count; i++)
|
||||
data[i] = static_cast<uint8_t>(i);
|
||||
ASSERT_EQ(::write(this->peer_fd_, data.data(), count), static_cast<ssize_t>(count));
|
||||
}
|
||||
std::string receive(size_t count) {
|
||||
std::string out;
|
||||
for (int i = 0; i < 50 && out.size() < count; i++) {
|
||||
this->pass();
|
||||
char buf[64];
|
||||
ssize_t n = ::recv(this->peer_fd_, buf, sizeof(buf), MSG_DONTWAIT);
|
||||
if (n > 0)
|
||||
out.append(buf, static_cast<size_t>(n));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
FakeUart uart_;
|
||||
UartTcp bridge_;
|
||||
binary_sensor::BinarySensor sensor_;
|
||||
int listen_fd_{-1};
|
||||
int peer_fd_{-1};
|
||||
uint32_t now_{100000};
|
||||
};
|
||||
|
||||
TEST_F(UartTcpClient, CopiesBothWays) {
|
||||
this->connect();
|
||||
this->uart_.feed("up");
|
||||
EXPECT_EQ(this->receive(2), "up");
|
||||
ASSERT_EQ(::write(this->peer_fd_, "down", 4), 4);
|
||||
for (int i = 0; i < 50 && this->uart_.tx.size() < 4; i++)
|
||||
this->pass();
|
||||
EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "down");
|
||||
}
|
||||
|
||||
TEST_F(UartTcpClient, DiscardsStaleUartBytesOnConnect) {
|
||||
// More than one discard chunk, so the drain loop runs several times.
|
||||
for (int i = 0; i < 100; i++)
|
||||
this->uart_.rx.push_back('s');
|
||||
this->connect();
|
||||
EXPECT_TRUE(this->uart_.rx.empty());
|
||||
this->uart_.feed("live");
|
||||
EXPECT_EQ(this->receive(4), "live");
|
||||
}
|
||||
|
||||
TEST_F(UartTcpClient, ReconnectsAndDropsBytesFromTheGap) {
|
||||
this->connect();
|
||||
this->close_peer();
|
||||
for (int i = 0; i < 50 && this->sensor_.state; i++)
|
||||
this->pass();
|
||||
ASSERT_FALSE(this->sensor_.state);
|
||||
this->uart_.feed("gap");
|
||||
this->connect();
|
||||
this->uart_.feed("new");
|
||||
EXPECT_EQ(this->receive(3), "new");
|
||||
}
|
||||
|
||||
TEST_F(UartTcpClient, PacesToTheDefaultLoopInterval) {
|
||||
this->connect();
|
||||
this->send(100);
|
||||
this->pass();
|
||||
this->pass();
|
||||
// 9600 baud at 10 bits per byte for 16 ms.
|
||||
EXPECT_EQ(this->uart_.writes, (std::vector<size_t>{15, 15}));
|
||||
}
|
||||
|
||||
TEST_F(UartTcpClient, PacesAnEarlyPassToTheTimeSinceTheLastWrite) {
|
||||
App.set_loop_interval(100);
|
||||
this->connect();
|
||||
this->send(200);
|
||||
this->pass(100);
|
||||
// A socket wake 5 ms later gets 5 ms of UART time, not a full interval.
|
||||
this->pass(5);
|
||||
this->pass(100);
|
||||
EXPECT_EQ(this->uart_.writes, (std::vector<size_t>{96, 4, 96}));
|
||||
}
|
||||
|
||||
TEST_F(UartTcpClient, CapsALongGapAtOneLoopInterval) {
|
||||
App.set_loop_interval(100);
|
||||
this->connect();
|
||||
this->send(250);
|
||||
// A 1000 ms gap gets one interval (96 bytes), a 50 ms pass gets 50 ms.
|
||||
this->pass(1000);
|
||||
this->pass(50);
|
||||
this->pass(1000);
|
||||
EXPECT_EQ(this->uart_.writes, (std::vector<size_t>{96, 48, 96}));
|
||||
}
|
||||
|
||||
TEST_F(UartTcpClient, CapsTheSpanAtFourSeconds) {
|
||||
App.set_loop_interval(10000);
|
||||
this->uart_.set_baud_rate(300);
|
||||
this->connect();
|
||||
this->send(200);
|
||||
// 300 baud is 30 bytes/s: a 6000 ms gap gets 4 s, a 1000 ms pass gets 1 s.
|
||||
this->pass(6000);
|
||||
this->pass(1000);
|
||||
EXPECT_EQ(this->uart_.writes, (std::vector<size_t>{120, 30}));
|
||||
}
|
||||
|
||||
TEST_F(UartTcpClient, PacesEachBaudRate) {
|
||||
this->connect();
|
||||
this->send(1000);
|
||||
// 16 ms and 1 ms passes; a write is at most one 128-byte read chunk.
|
||||
for (uint32_t baud : {9600, 115200, 921600}) {
|
||||
this->uart_.set_baud_rate(baud);
|
||||
this->pass(16);
|
||||
this->pass(1);
|
||||
}
|
||||
EXPECT_EQ(this->uart_.writes, (std::vector<size_t>{15, 1, 128, 11, 128, 92}));
|
||||
}
|
||||
|
||||
TEST_F(UartTcpClient, DoesNotOverflowAtAHighBaudRate) {
|
||||
App.set_loop_interval(10000);
|
||||
// baud * 4000 wraps a 32-bit product to 3520 at this rate.
|
||||
this->uart_.set_baud_rate(5368710);
|
||||
this->connect();
|
||||
this->send(200);
|
||||
this->pass(6000);
|
||||
EXPECT_EQ(this->uart_.writes, (std::vector<size_t>{128}));
|
||||
}
|
||||
|
||||
TEST_F(UartTcpClient, WritesAtLeastOneBytePerPass) {
|
||||
this->connect();
|
||||
this->send(100);
|
||||
this->pass();
|
||||
// Less than one byte of UART time since the last write still moves a byte.
|
||||
this->pass(0);
|
||||
this->pass(1);
|
||||
EXPECT_EQ(this->uart_.writes, (std::vector<size_t>{15, 1, 1}));
|
||||
}
|
||||
|
||||
TEST_F(UartTcpClient, FullUartHoldsSocketBytesUntilThereIsRoom) {
|
||||
this->connect();
|
||||
this->uart_.room = 0;
|
||||
this->send(40);
|
||||
for (int i = 0; i < 5; i++)
|
||||
this->pass();
|
||||
EXPECT_TRUE(this->uart_.tx.empty());
|
||||
EXPECT_TRUE(this->sensor_.state);
|
||||
this->uart_.room = 16;
|
||||
this->pass();
|
||||
ASSERT_EQ(this->uart_.writes.size(), 1u);
|
||||
EXPECT_EQ(this->uart_.writes.front(), 16u);
|
||||
this->uart_.room = SIZE_MAX;
|
||||
for (int i = 0; i < 50 && this->uart_.tx.size() < 40; i++)
|
||||
this->pass();
|
||||
ASSERT_EQ(this->uart_.tx.size(), 40u);
|
||||
for (size_t i = 0; i < 40; i++)
|
||||
EXPECT_EQ(this->uart_.tx[i], static_cast<uint8_t>(i));
|
||||
}
|
||||
|
||||
} // namespace esphome::uart_tcp::testing
|
||||
|
||||
#endif
|
||||
@@ -12,6 +12,10 @@ uart:
|
||||
- id: uart_bus
|
||||
baud_rate: 115200
|
||||
port: PTY_PATH
|
||||
# uart_tcp needs its own UART.
|
||||
- id: denied_bus
|
||||
baud_rate: 115200
|
||||
port: DENIED_PTY_PATH
|
||||
|
||||
uart_tcp:
|
||||
- id: bridge
|
||||
@@ -23,7 +27,7 @@ uart_tcp:
|
||||
connected:
|
||||
name: Bridge Connected
|
||||
- id: denied_bridge
|
||||
uart_id: uart_bus
|
||||
uart_id: denied_bus
|
||||
role: server
|
||||
port: 18127
|
||||
allowed_ips:
|
||||
|
||||
@@ -27,13 +27,17 @@ async def test_uart_tcp_bridge(
|
||||
server_port = unused_tcp_port_factory()
|
||||
denied_port = unused_tcp_port_factory()
|
||||
controller_fd, device_fd = os.openpty()
|
||||
denied_controller_fd, denied_device_fd = os.openpty()
|
||||
os.set_blocking(controller_fd, False)
|
||||
# uart's validate_port wants a two segment device path; Linux ptys live at
|
||||
# /dev/pts/N, so hand the config a /tmp symlink instead.
|
||||
pty_link = f"/tmp/uart-tcp-pty-{os.getpid()}"
|
||||
pathlib.Path(pty_link).symlink_to(os.ttyname(device_fd))
|
||||
denied_pty_link = f"/tmp/uart-tcp-denied-pty-{os.getpid()}"
|
||||
pathlib.Path(denied_pty_link).symlink_to(os.ttyname(denied_device_fd))
|
||||
yaml_config = yaml_config.replace("port: 18126", f"port: {server_port}")
|
||||
yaml_config = yaml_config.replace("port: 18127", f"port: {denied_port}")
|
||||
yaml_config = yaml_config.replace("DENIED_PTY_PATH", denied_pty_link)
|
||||
yaml_config = yaml_config.replace("PTY_PATH", pty_link)
|
||||
|
||||
lines = LineWaiter()
|
||||
@@ -117,4 +121,7 @@ async def test_uart_tcp_bridge(
|
||||
loop.remove_reader(controller_fd)
|
||||
os.close(controller_fd)
|
||||
os.close(device_fd)
|
||||
os.close(denied_controller_fd)
|
||||
os.close(denied_device_fd)
|
||||
pathlib.Path(pty_link).unlink()
|
||||
pathlib.Path(denied_pty_link).unlink()
|
||||
|
||||
Reference in New Issue
Block a user