[modbus] Speed up the bus with microsecond-accurate timing (#12421)

This commit is contained in:
Bonne Eggleston
2026-08-30 03:14:12 -05:00
committed by GitHub
parent cd28a8a03e
commit a811aa840c
4 changed files with 203 additions and 73 deletions
+117 -65
View File
@@ -3,6 +3,7 @@
#include <algorithm>
#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<uint32_t>(1u, this->parent_->get_baud_rate());
this->bits_per_char_ = static_cast<uint8_t>(
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::span<con
ModbusDeviceCommand *cmd = this->waiting_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<con
if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) {
ESP_LOGW(TAG,
"Received incorrect frame address %" PRIu8 " <> %" 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::span<con
// cleared-interrupted frame still ends in on_no_response rather than delivering a late response.
ESP_LOGW(TAG,
"Ignoring response from %" PRIu8 " - transmission interrupted by previous unexpected response, %" PRIu32
"ms after last send",
address, this->last_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::span<con
this->sweep_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<ExceptionCode>(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<uint32_t>(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<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
+14 -7
View File
@@ -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.
+8 -1
View File
@@ -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; }
@@ -0,0 +1,64 @@
#include <gtest/gtest.h>
#include <cstdint>
#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