diff --git a/CODEOWNERS b/CODEOWNERS index 12c0a54ac1..5f89315d23 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -560,6 +560,7 @@ esphome/components/tc74/* @sethgirvan esphome/components/tca9548a/* @andreashergert1984 esphome/components/tca9555/* @mobrembski esphome/components/tcl112/* @glmnet +esphome/components/tcp_uart/* @Bascht74 esphome/components/tee501/* @Stock-M esphome/components/teleinfo/* @0hax esphome/components/tem3200/* @bakerkj diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py new file mode 100644 index 0000000000..20866d1a0c --- /dev/null +++ b/esphome/components/tcp_uart/__init__.py @@ -0,0 +1,67 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor, socket, uart +from esphome.components.const import ( + CONF_DATA_BITS, + CONF_HOST, + CONF_PARITY, + CONF_STOP_BITS, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_BAUD_RATE, + CONF_ID, + CONF_PORT, + DEVICE_CLASS_CONNECTIVITY, + ENTITY_CATEGORY_DIAGNOSTIC, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@Bascht74"] +DEPENDENCIES = ["network"] +AUTO_LOAD = ["uart", "binary_sensor", "socket"] +MULTI_CONF = True + +tcp_uart_ns = cg.esphome_ns.namespace("tcp_uart") +TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component) + +CONF_RECONNECT_INTERVAL = "reconnect_interval" +CONF_CONNECTED = "connected" + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TcpUart), + cv.Required(CONF_HOST): cv.string, + cv.Required(CONF_PORT): cv.port, + cv.Optional(CONF_BAUD_RATE, default=9600): cv.int_range(min=1), + cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), + cv.Optional(CONF_PARITY, default="NONE"): cv.enum( + uart.UART_PARITY_OPTIONS, upper=True + ), + cv.Optional(CONF_STOP_BITS, default=1): cv.one_of(1, 2, int=True), + cv.Optional( + CONF_RECONNECT_INTERVAL, default="5s" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ).extend(cv.COMPONENT_SCHEMA), + socket.consume_sockets(1, "tcp_uart"), +) + + +async def to_code(config: ConfigType) -> None: + socket.require_tcp_client_link() + var = cg.new_Pvariable(config[CONF_ID], config[CONF_HOST], config[CONF_PORT]) + await cg.register_component(var, config) + cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL])) + # The socket is not clocked. These only satisfy UARTComponent and a consumer check. + cg.add(var.set_baud_rate(config[CONF_BAUD_RATE])) + cg.add(var.set_data_bits(config[CONF_DATA_BITS])) + cg.add(var.set_stop_bits(config[CONF_STOP_BITS])) + cg.add(var.set_parity(config[CONF_PARITY])) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) diff --git a/esphome/components/tcp_uart/tcp_uart.cpp b/esphome/components/tcp_uart/tcp_uart.cpp new file mode 100644 index 0000000000..fc81f6ecfe --- /dev/null +++ b/esphome/components/tcp_uart/tcp_uart.cpp @@ -0,0 +1,131 @@ +#include "tcp_uart.h" + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::tcp_uart { + +static const char *const TAG = "tcp_uart"; + +static constexpr uint32_t DROP_LOG_INTERVAL_MS = 5000; + +void TcpUart::setup() { + this->link_.begin(TAG); + if (this->connected_sensor_ != nullptr) { + this->connected_sensor_->publish_state(false); + } +} + +void TcpUart::dump_config() { + ESP_LOGCONFIG(TAG, + "TCP UART:\n" + " Host: %s:%u\n" + " Reconnect Interval: %" PRIu32 "ms", + this->link_.host(), this->link_.port(), this->link_.reconnect_interval()); + LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +} + +void TcpUart::sync_link_() { + bool up = this->link_.connected(); + this->link_was_up_ = up; + if (!up) { + this->rx_start_ = this->rx_end_ = 0; + this->tx_len_ = 0; + } + if (this->connected_sensor_ != nullptr) { + this->connected_sensor_->publish_state(up); + } +} + +void TcpUart::read_socket_() { + if (this->rx_start_ != 0) { + this->rx_end_ -= this->rx_start_; + std::memmove(this->rx_, this->rx_ + this->rx_start_, this->rx_end_); + this->rx_start_ = 0; + } + size_t room = RX_BUFFER_SIZE - this->rx_end_; + if (room == 0) { + // Only a read that filled all free space gets here, so rx_pending_ is already set. + return; + } + ssize_t count = this->link_.read(this->rx_ + this->rx_end_, room); + if (count <= 0) { + // A dropped link (-1) is cleaned up by sync_link_() on the next loop. + if (count == 0) { + this->rx_pending_ = false; + } + return; + } + this->rx_end_ += static_cast(count); + this->rx_pending_ = static_cast(count) == room; +} + +void TcpUart::flush_tx_() { + ssize_t sent = this->link_.write(this->tx_, this->tx_len_); + if (sent > 0) { + this->tx_len_ -= static_cast(sent); + std::memmove(this->tx_, this->tx_ + sent, this->tx_len_); + } +} + +void TcpUart::loop() { + this->link_.poll(); + if (this->link_.connected() != this->link_was_up_) { + this->sync_link_(); + } + if (!this->link_was_up_) { + return; + } + if (this->rx_pending_ || this->link_.ready()) { + this->read_socket_(); + } + if (this->tx_len_ != 0) { + this->flush_tx_(); + } +} + +void TcpUart::write_array(const uint8_t *data, size_t len) { + size_t room = this->link_.connected() ? sizeof(this->tx_) - this->tx_len_ : 0; + if (len > room) { + uint32_t now = App.get_loop_component_start_time(); + if (this->last_drop_log_ms_ == 0 || now - this->last_drop_log_ms_ >= DROP_LOG_INTERVAL_MS) { + ESP_LOGW(TAG, "%s, dropped %u bytes", + this->link_.connected() ? LOG_STR_LITERAL("TX buffer full") : LOG_STR_LITERAL("Not connected"), + static_cast(len - room)); + this->last_drop_log_ms_ = now; + } + len = room; + } + std::memcpy(this->tx_ + this->tx_len_, data, len); + this->tx_len_ += static_cast(len); +} + +bool TcpUart::peek_byte(uint8_t *data) { + if (this->rx_start_ == this->rx_end_) { + return false; + } + *data = this->rx_[this->rx_start_]; + return true; +} + +bool TcpUart::read_array(uint8_t *data, size_t len) { + if (this->available() < len) { + return false; + } + std::memcpy(data, this->rx_ + this->rx_start_, len); + this->rx_start_ += static_cast(len); + return true; +} + +uart::UARTFlushResult TcpUart::flush() { + this->flush_tx_(); + if (this->tx_len_ == 0) { + return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; + } + return uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; +} + +} // namespace esphome::tcp_uart diff --git a/esphome/components/tcp_uart/tcp_uart.h b/esphome/components/tcp_uart/tcp_uart.h new file mode 100644 index 0000000000..c13d2ef6f0 --- /dev/null +++ b/esphome/components/tcp_uart/tcp_uart.h @@ -0,0 +1,66 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/socket/tcp_client_link.h" +#include "esphome/components/uart/uart_component.h" +#include "esphome/core/component.h" + +#include + +namespace esphome::tcp_uart { + +/// TCP client presented as a UART. Bytes are copied unchanged. +class TcpUart : public uart::UARTComponent, public Component { + public: + TcpUart(const char *host, uint16_t port) { + this->link_.set_host(host); + this->link_.set_port(port); + this->rx_buffer_size_ = RX_BUFFER_SIZE; + } + + void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } + void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } + + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override { this->link_.close(); } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + + void write_array(const uint8_t *data, size_t len) override; + bool peek_byte(uint8_t *data) override; + bool read_array(uint8_t *data, size_t len) override; + size_t available() override { return static_cast(this->rx_end_ - this->rx_start_); } + // Same room write_array() grants, so consumers can apply backpressure. + size_t available_for_write() override { return this->link_.connected() ? sizeof(this->tx_) - this->tx_len_ : 0; } + uart::UARTFlushResult flush() override; + bool is_connected() override { return this->link_.connected(); } +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif + + protected: + void check_logger_conflict() override {} + void sync_link_(); + void read_socket_(); + void flush_tx_(); + + static constexpr size_t RX_BUFFER_SIZE = 1024; + static constexpr size_t TX_BUFFER_SIZE = 1024; + + socket::TcpClientLink link_; + binary_sensor::BinarySensor *connected_sensor_{nullptr}; + uint32_t last_drop_log_ms_{0}; + uint16_t tx_len_{0}; + // rx_[rx_start_, rx_end_) holds unread bytes; read_socket_() compacts to the front. + uint16_t rx_start_{0}; + uint16_t rx_end_{0}; + // The link state loop() saw last; edges clear the buffers and publish the sensor. + bool link_was_up_{false}; + // A read stopped before EAGAIN. ready() stays false until new data arrives. + bool rx_pending_{false}; + uint8_t rx_[RX_BUFFER_SIZE]{}; + uint8_t tx_[TX_BUFFER_SIZE]{}; +}; + +} // namespace esphome::tcp_uart diff --git a/tests/components/tcp_uart/common.yaml b/tests/components/tcp_uart/common.yaml new file mode 100644 index 0000000000..f3c1d061aa --- /dev/null +++ b/tests/components/tcp_uart/common.yaml @@ -0,0 +1,20 @@ +wifi: + ssid: MySSID + password: password1 + +tcp_uart: + - id: tcp_uart_1 + host: 192.0.2.10 + port: 502 + reconnect_interval: 10s + connected: + name: TCP UART Connected + +interval: + - interval: 60s + then: + - lambda: |- + uint8_t byte; + if (id(tcp_uart_1).available() && id(tcp_uart_1).read_byte(&byte)) { + id(tcp_uart_1).write_byte(byte); + } diff --git a/tests/components/tcp_uart/test.bk72xx-ard.yaml b/tests/components/tcp_uart/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..2d42c282f7 --- /dev/null +++ b/tests/components/tcp_uart/test.bk72xx-ard.yaml @@ -0,0 +1,2 @@ +packages: + tcp_uart: !include common.yaml diff --git a/tests/components/tcp_uart/test.esp32-idf.yaml b/tests/components/tcp_uart/test.esp32-idf.yaml new file mode 100644 index 0000000000..2d42c282f7 --- /dev/null +++ b/tests/components/tcp_uart/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + tcp_uart: !include common.yaml diff --git a/tests/components/tcp_uart/test.esp8266-ard.yaml b/tests/components/tcp_uart/test.esp8266-ard.yaml new file mode 100644 index 0000000000..2d42c282f7 --- /dev/null +++ b/tests/components/tcp_uart/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + tcp_uart: !include common.yaml diff --git a/tests/components/tcp_uart/test.host.yaml b/tests/components/tcp_uart/test.host.yaml new file mode 100644 index 0000000000..22d7f7fbaa --- /dev/null +++ b/tests/components/tcp_uart/test.host.yaml @@ -0,0 +1,4 @@ +packages: + tcp_uart: !include common.yaml + +wifi: !remove diff --git a/tests/components/tcp_uart/test.ln882x-ard.yaml b/tests/components/tcp_uart/test.ln882x-ard.yaml new file mode 100644 index 0000000000..2d42c282f7 --- /dev/null +++ b/tests/components/tcp_uart/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + tcp_uart: !include common.yaml diff --git a/tests/components/tcp_uart/test.rp2040-ard.yaml b/tests/components/tcp_uart/test.rp2040-ard.yaml new file mode 100644 index 0000000000..2d42c282f7 --- /dev/null +++ b/tests/components/tcp_uart/test.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + tcp_uart: !include common.yaml diff --git a/tests/components/tcp_uart/test.rtl87xx-ard.yaml b/tests/components/tcp_uart/test.rtl87xx-ard.yaml new file mode 100644 index 0000000000..2d42c282f7 --- /dev/null +++ b/tests/components/tcp_uart/test.rtl87xx-ard.yaml @@ -0,0 +1,2 @@ +packages: + tcp_uart: !include common.yaml