diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index 6e09ecac23..6e137073f4 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -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) diff --git a/esphome/components/uart_tcp/uart_tcp.cpp b/esphome/components/uart_tcp/uart_tcp.cpp index cf15802b55..7389737643 100644 --- a/esphome/components/uart_tcp/uart_tcp.cpp +++ b/esphome/components/uart_tcp/uart_tcp.cpp @@ -1,5 +1,6 @@ #include "uart_tcp.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" #include @@ -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(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(1, paced); } if (room == 0) { this->rx_pending_ = true; @@ -81,11 +86,12 @@ void UartTcp::read_socket_() { } this->rx_pending_ = static_cast(count) == want; this->write_array(tmp, static_cast(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)); diff --git a/esphome/components/uart_tcp/uart_tcp.h b/esphome/components/uart_tcp/uart_tcp.h index f557cca33f..e270b6df35 100644 --- a/esphome/components/uart_tcp/uart_tcp.h +++ b/esphome/components/uart_tcp/uart_tcp.h @@ -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}; diff --git a/tests/component_tests/uart_tcp/__init__.py b/tests/component_tests/uart_tcp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/uart_tcp/test_init.py b/tests/component_tests/uart_tcp/test_init.py new file mode 100644 index 0000000000..e01fb2c3e9 --- /dev/null +++ b/tests/component_tests/uart_tcp/test_init.py @@ -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")) diff --git a/tests/components/uart_tcp/__init__.py b/tests/components/uart_tcp/__init__.py new file mode 100644 index 0000000000..16c7cd1ff1 --- /dev/null +++ b/tests/components/uart_tcp/__init__.py @@ -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 diff --git a/tests/components/uart_tcp/test_uart_tcp_host.cpp b/tests/components/uart_tcp/test_uart_tcp_host.cpp new file mode 100644 index 0000000000..003cb81cd9 --- /dev/null +++ b/tests/components/uart_tcp/test_uart_tcp_host.cpp @@ -0,0 +1,275 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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(*p)); + } + + std::vector rx; + std::vector tx; + std::vector 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(&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(&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 data(count); + for (size_t i = 0; i < count; i++) + data[i] = static_cast(i); + ASSERT_EQ(::write(this->peer_fd_, data.data(), count), static_cast(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(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{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{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{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{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{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{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{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(i)); +} + +} // namespace esphome::uart_tcp::testing + +#endif diff --git a/tests/integration/fixtures/uart_tcp_bridge.yaml b/tests/integration/fixtures/uart_tcp_bridge.yaml index 2ab70a03b6..7ad28316f7 100644 --- a/tests/integration/fixtures/uart_tcp_bridge.yaml +++ b/tests/integration/fixtures/uart_tcp_bridge.yaml @@ -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: diff --git a/tests/integration/test_uart_tcp_bridge.py b/tests/integration/test_uart_tcp_bridge.py index 909df32410..33d32f4c07 100644 --- a/tests/integration/test_uart_tcp_bridge.py +++ b/tests/integration/test_uart_tcp_bridge.py @@ -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()