diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index aa998d283a..f77492f48b 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -3,6 +3,7 @@ #include #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -12,29 +13,49 @@ static const char *const TAG = "modbus"; static constexpr size_t MODBUS_MAX_LOG_BYTES = 64; -// Approximate bits per character on the wire (depends on parity/stop bit config) -static constexpr uint32_t MODBUS_BITS_PER_CHAR = 11; -static constexpr uint32_t MS_PER_SEC = 1000; +static constexpr uint32_t US_PER_SEC = 1000000; +static constexpr uint32_t US_PER_MS = 1000; + +// Minimum interframe delay per the Modbus spec (fixed 1750us above 19200 baud) +static constexpr uint32_t MODBUS_MIN_FRAME_DELAY_US = 1750; + +// Diagnostics only: the backdated byte stamp can precede last_send_ (echo, or noise during our own +// send), where an unsigned wrap would print ~4.29e9. +static uint32_t us_since_send(uint32_t last_modbus_byte, uint32_t last_send) { + const uint32_t elapsed = last_modbus_byte - last_send; + return (int32_t) elapsed < 0 ? 0 : elapsed; +} void Modbus::setup() { if (this->flow_control_pin_ != nullptr) { this->flow_control_pin_->setup(); } - this->frame_delay_ms_ = - std::max(2, // 1750us minimum per spec - rounded up to 2ms. - // 3.5 characters * 11 bits per character * 1000ms/sec / (bits/sec) (Standard modbus frame delay) - (uint16_t) (3.5 * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate()) + 1); + // RTU specifies 11 bits per character but 8N1 is 10, so derive it from the framing. The schema + // forbids a zero, so one here means the hub never set it (weikai): fall back to 8N1 and a 1 baud floor. + const uint8_t data_bits = this->parent_->get_data_bits() != 0 ? this->parent_->get_data_bits() : 8; + const uint8_t stop_bits = this->parent_->get_stop_bits() != 0 ? this->parent_->get_stop_bits() : 1; + const uint32_t baud_rate = std::max(1u, this->parent_->get_baud_rate()); + this->bits_per_char_ = static_cast( + 1 + data_bits + (this->parent_->get_parity() == uart::UART_CONFIG_PARITY_NONE ? 0 : 1) + stop_bits); + + // 3.5 characters * bits per character * 1e6 us/sec / (bits/sec) (Standard modbus frame delay) + this->frame_delay_us_ = + std::max(MODBUS_MIN_FRAME_DELAY_US, (uint32_t) (3.5 * this->bits_per_char_ * US_PER_SEC / baud_rate) + 1); // When rx_full_threshold is configured (non-zero), the UART has a hardware FIFO with a // meaningful threshold (e.g., ESP32 native UART), so we can calculate a precise delay. // Otherwise (e.g., USB UART), use 50ms to handle data arriving in chunks. - static constexpr uint16_t DEFAULT_LONG_RX_BUFFER_DELAY_MS = 50; + static constexpr uint32_t DEFAULT_LONG_RX_BUFFER_DELAY_US = 50 * US_PER_MS; size_t rx_threshold = this->parent_->get_rx_full_threshold(); - this->long_rx_buffer_delay_ms_ = - rx_threshold != uart::UARTComponent::RX_FULL_THRESHOLD_UNSET - ? (rx_threshold * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate()) + 1 - : DEFAULT_LONG_RX_BUFFER_DELAY_MS; + this->long_rx_buffer_delay_us_ = rx_threshold != uart::UARTComponent::RX_FULL_THRESHOLD_UNSET + ? (uint32_t) (rx_threshold * this->bits_per_char_ * US_PER_SEC / baud_rate) + 1 + : DEFAULT_LONG_RX_BUFFER_DELAY_US; + + // The idle-timeout interrupt fires rx_timeout characters after the last byte, so that much silence + // has already passed by the time we read it: backdate so the gap measures silence on the wire. + this->rx_detect_latency_us_ = + (uint32_t) (this->parent_->get_rx_timeout() * this->bits_per_char_ * US_PER_SEC / baud_rate); } void Modbus::loop() { @@ -52,7 +73,7 @@ void ModbusClientHub::loop() { // Send-wait watchdog: only the cheap time check runs at loop rate; expire_waiting_() looks the // entry up and holds off if the response has started arriving. if (this->waiting_for_response_ && - this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_) { + this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_us_) { this->expire_waiting_(); } @@ -72,7 +93,7 @@ void ModbusClientHub::expire_waiting_() { } // Only a genuine WAITING entry warrants the log (a cleared or interrupted shell timing out is expected). if (cmd->state == FrameState::WAITING) { - ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", cmd->frame.address(), + ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "us after last send", cmd->frame.address(), this->last_receive_check_ - this->last_send_); } // Deliver on_no_response directly, the way the parse path delivers response()/error(): the entry @@ -86,37 +107,47 @@ void ModbusClientHub::expire_waiting_() { bool Modbus::timeout_() { // If the response frame is finished (including interframe delay) - we timeout. // The long_rx_buffer_delay accounts for long responses (larger than the UART rx_full_threshold) to avoid timeouts - // when the buffer is filling the back half of the response - const uint16_t timeout = std::max( - (uint16_t) this->frame_delay_ms_, - (uint16_t) (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold() ? this->long_rx_buffer_delay_ms_ - : 0)); + // when the buffer is filling the back half of the response. The latch decides, not the current size: + // parsing a leading frame can shrink the buffer below the threshold while the rest is still streaming. + // The latency term covers the final batch, which is idle-delivered. + const uint32_t timeout = + this->exceeded_rx_full_threshold_ + ? std::max(this->frame_delay_us_, this->long_rx_buffer_delay_us_ + this->rx_detect_latency_us_) + : this->frame_delay_us_; return this->last_receive_check_ - this->last_modbus_byte_ > timeout; } +// We use micros() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps +// It's critical in all timestamp comparisons that the left timestamp comes before the right one in time +// If we use a cached value in place of micros() and last_modbus_byte_ is updated inside our loop +// then the comparison is backwards (small negative which wraps to large positive) and will cause a false timeout +// So in this component we don't use any cached timestamp values to avoid these annoying bugs. +// Compare before subtracting: a signed difference would read a bus idle past half the micros() wrap +// (~35 min) as a huge delay still owed. +static inline uint32_t remaining_delay(uint32_t elapsed, uint32_t required) { + return elapsed >= required ? 0 : required - elapsed; +} + int32_t Modbus::tx_delay_remaining() { - // millis() here and everywhere in this component, never a cached loop timestamp: a cached "now" can - // predate last_modbus_byte_, and the unsigned subtraction then wraps huge and forces a false timeout. - const uint32_t now = millis(); - return std::max({(int32_t) 0, - (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ - (now - this->last_send_)), - (int32_t) (this->frame_delay_ms_ - (now - this->last_modbus_byte_))}); + const uint32_t now = micros(); + return (int32_t) std::max(remaining_delay(now - this->last_send_, this->last_send_tx_offset_ + this->frame_delay_us_), + remaining_delay(now - this->last_modbus_byte_, this->frame_delay_us_)); } int32_t ModbusClientHub::tx_delay_remaining() { - const uint32_t now = millis(); - return std::max({(int32_t) 0, - (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ - - (now - this->last_send_)), - (int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))}); + const uint32_t now = micros(); + return (int32_t) std::max( + remaining_delay(now - this->last_send_, + this->last_send_tx_offset_ + this->frame_delay_us_ + this->turnaround_delay_us_), + remaining_delay(now - this->last_modbus_byte_, this->frame_delay_us_ + this->turnaround_delay_us_)); } bool Modbus::tx_blocked() { // Blocked while any rx bytes are pending, or within tx_delay of the last byte in either direction // (receivers must see our previous tx as done, and more rx may be coming). A remaining delay up to - // MODBUS_TX_MAX_DELAY_MS doesn't block - send_frame_ absorbs it instead of looping on small waits. - return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_MS; + // MODBUS_TX_MAX_DELAY_US doesn't block - send_frame_ absorbs it instead of looping on small waits. + return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_US; } bool ModbusClientHub::tx_blocked() { return this->waiting_for_response_ || this->Modbus::tx_blocked(); } @@ -133,20 +164,26 @@ bool ModbusClientHub::tx_buffer_empty() { } void Modbus::receive_bytes_() { - this->last_receive_check_ = millis(); + this->last_receive_check_ = micros(); size_t bytes = this->available(); if (bytes) { size_t buffer_size = this->rx_buffer_.size(); - this->last_modbus_byte_ = this->last_receive_check_; + // Below the threshold the batch can only be idle-delivered, so its last byte finished one detection + // latency ago; at or above it the frame may still be streaming, so stamp now. + this->last_modbus_byte_ = bytes < this->parent_->get_rx_full_threshold() + ? this->last_receive_check_ - this->rx_detect_latency_us_ + : this->last_receive_check_; this->rx_buffer_.resize(buffer_size + bytes); if (!this->read_array(this->rx_buffer_.data() + buffer_size, bytes)) { this->rx_buffer_.resize(buffer_size); return; } + if (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold()) + this->exceeded_rx_full_threshold_ = true; if (buffer_size == 0) { - ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) of %zu bytes %" PRIu32 "ms after last send", - this->rx_buffer_[0], this->rx_buffer_[0], this->rx_buffer_.size(), millis() - this->last_send_); + ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) of %zu bytes %" PRIu32 "us after last send", + this->rx_buffer_[0], this->rx_buffer_[0], this->rx_buffer_.size(), micros() - this->last_send_); } } } @@ -299,8 +336,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::spanwaiting_for_response_ ? this->find_waiting_() : nullptr; if (cmd == nullptr) { ESP_LOGW(TAG, - "Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "ms after last send", - address, function_code, this->last_modbus_byte_ - this->last_send_); + "Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "us after last send", + address, function_code, us_since_send(this->last_modbus_byte_, this->last_send_)); return; } @@ -310,9 +347,9 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32 - "ms after last send", + "us after last send", address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code, - this->last_modbus_byte_ - this->last_send_); + us_since_send(this->last_modbus_byte_, this->last_send_)); // Unexpected frame: flip a WAITING entry to an INTERRUPTED shell that ignores the rest of this // transaction and blocks tx until the send-wait timeout, where it gets its on_no_response. cmd->interrupt(); @@ -325,8 +362,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::spanlast_modbus_byte_ - this->last_send_); + "us after last send", + address, us_since_send(this->last_modbus_byte_, this->last_send_)); return; } @@ -337,12 +374,12 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::spansweep_needed_ = true; if (helpers::is_function_code_exception(function_code)) { uint8_t exception = pdu[1]; // exception frames are fixed-length, so the code is always present - ESP_LOGW(TAG, "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send", - function_code, exception, address, this->last_modbus_byte_ - this->last_send_); + ESP_LOGW(TAG, "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "us after last send", + function_code, exception, address, us_since_send(this->last_modbus_byte_, this->last_send_)); cmd->error(static_cast(exception)); } else if (!cmd->response(pdu)) { - ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send", address, - this->last_modbus_byte_ - this->last_send_); + ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "us after last send", address, + us_since_send(this->last_modbus_byte_, this->last_send_)); } } @@ -738,9 +775,15 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func // Callers gate on tx_blocked() first, but the pre-send delay below can span several ms, so re-check // after it and refuse (return false) if a byte arrived in that window rather than transmit over it. bool Modbus::send_frame_(const ModbusFrame &frame) { - const int32_t tx_delay_remaining = this->tx_delay_remaining(); + int32_t tx_delay_remaining = this->tx_delay_remaining(); if (tx_delay_remaining > 0) { - delay(tx_delay_remaining); + // delay() only lands on tick boundaries, so yield with it to get close, then busy-wait the rest. + if (tx_delay_remaining > (int32_t) (2 * US_PER_MS)) { + delay((tx_delay_remaining - US_PER_MS) / US_PER_MS); + tx_delay_remaining = this->tx_delay_remaining(); + } + if (tx_delay_remaining > 0) + delayMicroseconds(tx_delay_remaining); } if (this->tx_blocked()) { @@ -755,14 +798,15 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { this->last_send_tx_offset_ = 0; } else { this->write_array(frame.data.data(), frame.size()); - this->last_send_tx_offset_ = frame.size() * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; + this->last_send_tx_offset_ = + frame.size() * this->bits_per_char_ * US_PER_SEC / std::max(1u, this->parent_->get_baud_rate()) + 1; } - uint32_t now = millis(); + uint32_t now = micros(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send, %" PRIu32 "ms after last receive", + ESP_LOGV(TAG, "Write: %s %" PRIu32 "us after last send, %" PRIu32 "us after last receive", format_hex_pretty_to(hex_buf, frame.data.data(), frame.size()), now - this->last_send_, now - this->last_modbus_byte_); this->last_send_ = now; @@ -800,20 +844,25 @@ void ModbusClientHub::send_next_frame_() { void ModbusClientHub::dump_config() { ESP_LOGCONFIG(TAG, "Modbus:\n" - " Send Wait Time: %" PRIu16 " ms\n" - " Turnaround Time: %" PRIu16 " ms\n" - " Frame Delay: %" PRIu16 " ms\n" - " Long Rx Buffer Delay: %" PRIu16 " ms", - this->send_wait_time_, this->turnaround_delay_ms_, this->frame_delay_ms_, - this->long_rx_buffer_delay_ms_); + " Send Wait Time: %" PRIu32 " ms\n" + " Turnaround Time: %" PRIu32 " ms\n" + " Frame Delay: %" PRIu32 " us\n" + " Long Rx Buffer Delay: %" PRIu32 " us\n" + " Bits Per Character: %" PRIu8 "\n" + " Rx Detect Latency: %" PRIu32 " us", + this->send_wait_time_us_ / US_PER_MS, this->turnaround_delay_us_ / US_PER_MS, this->frame_delay_us_, + this->long_rx_buffer_delay_us_, this->bits_per_char_, this->rx_detect_latency_us_); LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); } void ModbusServerHub::dump_config() { ESP_LOGCONFIG(TAG, "Modbus:\n" - " Frame Delay: %" PRIu16 " ms\n" - " Long Rx Buffer Delay: %" PRIu16 " ms", - this->frame_delay_ms_, this->long_rx_buffer_delay_ms_); + " Frame Delay: %" PRIu32 " us\n" + " Long Rx Buffer Delay: %" PRIu32 " us\n" + " Bits Per Character: %" PRIu8 "\n" + " Rx Detect Latency: %" PRIu32 " us", + this->frame_delay_us_, this->long_rx_buffer_delay_us_, this->bits_per_char_, + this->rx_detect_latency_us_); LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); } @@ -1142,7 +1191,8 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) { // without a heap allocation. Only one server reply is ever waiting, so a single buffer suffices. std::memcpy(this->deferred_payload_.data(), payload, len); this->deferred_payload_len_ = len; - this->set_timeout("deferred_send", this->tx_delay_remaining(), [this]() { + // set_timeout() takes milliseconds; round the microsecond delay up so we never fire early. + this->set_timeout("deferred_send", (this->tx_delay_remaining() + US_PER_MS - 1) / US_PER_MS, [this]() { ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1, this->deferred_payload_len_ - 1); if (!this->send_frame_(frame)) @@ -1162,11 +1212,11 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_t bytes = bytes_to_clear; if (bytes > 0) { if (warn) { - ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason), - millis() - this->last_send_); + ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "us after last send", bytes, LOG_STR_ARG(reason), + micros() - this->last_send_); } else { - ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason), - millis() - this->last_send_); + ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "us after last send", bytes, LOG_STR_ARG(reason), + micros() - this->last_send_); } if (bytes == this->rx_buffer_.size()) { this->rx_buffer_.clear(); @@ -1174,6 +1224,8 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_t this->rx_buffer_.erase(this->rx_buffer_.begin(), this->rx_buffer_.begin() + bytes); } } + if (this->rx_buffer_.empty()) + this->exceeded_rx_full_threshold_ = false; } void ModbusClientDevice::dispatch_response_(std::span request_pdu, std::span response_pdu, diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 69a7eb82e3..7d7818239d 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -19,7 +19,7 @@ namespace esphome::modbus { // Tx queue backstop: duplicates dedup into one entry, so only a runaway generator of distinct frames // (e.g. a loop writing a changing value) could grow the heap unboundedly. static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 128; -static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5; +static constexpr uint16_t MODBUS_TX_MAX_DELAY_US = 5000; // Typical frames -- reads and single-register/coil writes -- are exactly 8 bytes // (address + 5-byte PDU + 2-byte CRC). @@ -70,12 +70,18 @@ class Modbus : public uart::UARTDevice, public Component { bool send_frame_(const ModbusFrame &frame); uint16_t find_frame_end_by_crc_(uint16_t min_length) const; + // All timestamps and durations below are micros()-based uint32_t last_modbus_byte_{0}; uint32_t last_receive_check_{0}; uint32_t last_send_{0}; uint32_t last_send_tx_offset_{0}; - uint16_t frame_delay_ms_{5}; - uint16_t long_rx_buffer_delay_ms_{0}; + uint32_t frame_delay_us_{5000}; + uint32_t long_rx_buffer_delay_us_{0}; + uint32_t rx_detect_latency_us_{0}; + // Bits on the wire per character (start + data + optional parity + stop); 12 at most. + uint8_t bits_per_char_{11}; + // Latched when a read reaches rx_full_threshold, cleared when the buffer drains. + bool exceeded_rx_full_threshold_{false}; GPIOPin *flow_control_pin_{nullptr}; @@ -232,8 +238,9 @@ class ModbusClientHub : public Modbus { ModbusClientHub() = default; void dump_config() override; void loop() override; - void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_ = time_in_ms; } - void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } + // Config arrives in milliseconds; stored internally in microseconds like all other timing. + void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_us_ = time_in_ms * 1000UL; } + void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_us_ = time_in_ms * 1000UL; } bool tx_buffer_empty(); bool tx_blocked() override; ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") @@ -279,8 +286,8 @@ class ModbusClientHub : public Modbus { // End the wait for a response on send-wait timeout (the loop() watchdog body); see FrameState. void expire_waiting_(); - uint16_t send_wait_time_{2000}; - uint16_t turnaround_delay_ms_{0}; + uint32_t send_wait_time_us_{2000000}; + uint32_t turnaround_delay_us_{0}; // Set on transmit, cleared on the transaction-ending transition; send_next_frame_ won't select // while it is set, so at most one frame is awaiting a response. diff --git a/tests/components/modbus/common.h b/tests/components/modbus/common.h index e6c37b0e6d..83d30b3f6d 100644 --- a/tests/components/modbus/common.h +++ b/tests/components/modbus/common.h @@ -11,7 +11,14 @@ namespace esphome::modbus::testing { // A UART that discards all writes, for tests that never inspect the wire. class NullUART : public uart::UARTComponent { public: - NullUART() { this->set_baud_rate(115200); } + // 8N1, matching what the uart schema emits for a real hub; the framing drives the modbus + // interframe timing, so leaving data/stop bits at their zero defaults would not be representative. + NullUART() { + this->set_baud_rate(115200); + this->set_data_bits(8); + this->set_stop_bits(1); + this->set_parity(uart::UART_CONFIG_PARITY_NONE); + } void write_array(const uint8_t *data, size_t len) override {} bool peek_byte(uint8_t *data) override { return false; } bool read_array(uint8_t *data, size_t len) override { return false; } diff --git a/tests/components/modbus/modbus_framing_test.cpp b/tests/components/modbus/modbus_framing_test.cpp new file mode 100644 index 0000000000..a102db5a51 --- /dev/null +++ b/tests/components/modbus/modbus_framing_test.cpp @@ -0,0 +1,64 @@ +#include + +#include + +#include "common.h" +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus::testing { + +namespace { + +// Exposes the timing values setup() derives from the UART framing. +class FramingProbeHub : public ModbusClientHub { + public: + uint32_t bits_per_char() const { return this->bits_per_char_; } + uint32_t frame_delay_us() const { return this->frame_delay_us_; } +}; + +class FramedUART : public NullUART { + public: + FramedUART(uint32_t baud_rate, uint8_t data_bits, uint8_t stop_bits, uart::UARTParityOptions parity) { + this->set_baud_rate(baud_rate); + this->set_data_bits(data_bits); + this->set_stop_bits(stop_bits); + this->set_parity(parity); + } +}; + +} // namespace + +// 8N1 is 10 bits on the wire, so t3.5 at 9600 baud is 3.5 * 10 / 9600 = 3645.8us. +TEST(ModbusFraming, EightNoneOneDerivesTenBits) { + FramedUART uart(9600, 8, 1, uart::UART_CONFIG_PARITY_NONE); + FramingProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + + EXPECT_EQ(hub.bits_per_char(), 10u); + EXPECT_EQ(hub.frame_delay_us(), 3646u); +} + +// Spec-conformant RTU framing is 11 bits, which lengthens the interframe gap to +// 3.5 * 11 / 9600 = 4010.4us, rounded up. +TEST(ModbusFraming, EightEvenOneDerivesElevenBits) { + FramedUART uart(9600, 8, 1, uart::UART_CONFIG_PARITY_EVEN); + FramingProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + + EXPECT_EQ(hub.bits_per_char(), 11u); + EXPECT_EQ(hub.frame_delay_us(), 4011u); +} + +// Above 19200 baud the spec's fixed 1750us floor governs instead of 3.5 characters. +TEST(ModbusFraming, FastBaudUsesSpecFloor) { + FramedUART uart(115200, 8, 1, uart::UART_CONFIG_PARITY_NONE); + FramingProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + + EXPECT_EQ(hub.frame_delay_us(), 1750u); +} + +} // namespace esphome::modbus::testing