[modbus] Fix parsing & split out server mode (#11969)

This commit is contained in:
Bonne Eggleston
2026-06-21 11:32:35 -07:00
committed by GitHub
parent dbdf125ec8
commit 63d8a344c5
20 changed files with 1211 additions and 532 deletions

View File

@@ -14,7 +14,11 @@ DEPENDENCIES = ["uart"]
modbus_ns = cg.esphome_ns.namespace("modbus") modbus_ns = cg.esphome_ns.namespace("modbus")
Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice)
ModbusServer = modbus_ns.class_("ModbusServerHub", Modbus)
ModbusClient = modbus_ns.class_("ModbusClientHub", Modbus)
ModbusDevice = modbus_ns.class_("ModbusDevice") ModbusDevice = modbus_ns.class_("ModbusDevice")
ModbusClientDevice = modbus_ns.class_("ModbusClientDevice")
ModbusServerDevice = modbus_ns.class_("ModbusServerDevice")
MULTI_CONF = True MULTI_CONF = True
CONF_ROLE = "role" CONF_ROLE = "role"
@@ -22,29 +26,43 @@ CONF_MODBUS_ID = "modbus_id"
CONF_SEND_WAIT_TIME = "send_wait_time" CONF_SEND_WAIT_TIME = "send_wait_time"
CONF_TURNAROUND_TIME = "turnaround_time" CONF_TURNAROUND_TIME = "turnaround_time"
ModbusRole = modbus_ns.enum("ModbusRole") MODBUS_ROLES = ["client", "server"]
MODBUS_ROLES = {
"client": ModbusRole.CLIENT,
"server": ModbusRole.SERVER,
}
CONFIG_SCHEMA = ( CONFIG_SCHEMA = cv.typed_schema(
cv.Schema(
{ {
cv.GenerateID(): cv.declare_id(Modbus), "client": cv.Schema(
cv.Optional(CONF_ROLE, default="client"): cv.enum(MODBUS_ROLES), {
cv.GenerateID(): cv.declare_id(ModbusClient),
cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema,
cv.Optional( cv.Optional(
CONF_SEND_WAIT_TIME, default="250ms" CONF_SEND_WAIT_TIME, default="2000ms"
): cv.positive_time_period_milliseconds, ): cv.positive_time_period_milliseconds,
cv.Optional( cv.Optional(
CONF_TURNAROUND_TIME, default="100ms" CONF_TURNAROUND_TIME, default="600ms"
): cv.positive_time_period_milliseconds, ): cv.positive_time_period_milliseconds,
cv.Optional(CONF_DISABLE_CRC, default=False): cv.boolean, # Remove before 2026.10.0
cv.Optional(CONF_DISABLE_CRC): cv.invalid(
"'disable_crc' has been removed. The parser no longer requires it — remove this option."
),
} }
) )
.extend(cv.COMPONENT_SCHEMA) .extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA) .extend(uart.UART_DEVICE_SCHEMA),
"server": cv.Schema(
{
cv.GenerateID(): cv.declare_id(ModbusServer),
cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema,
# Remove before 2026.10.0
cv.Optional(CONF_DISABLE_CRC): cv.invalid(
"'disable_crc' has been removed. The parser no longer requires it — remove this option."
),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA),
},
key=CONF_ROLE,
default_type="client",
) )
@@ -55,19 +73,19 @@ async def to_code(config):
await uart.register_uart_device(var, config) await uart.register_uart_device(var, config)
cg.add(var.set_role(config[CONF_ROLE]))
if CONF_FLOW_CONTROL_PIN in config: if CONF_FLOW_CONTROL_PIN in config:
pin = await gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) pin = await gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN])
cg.add(var.set_flow_control_pin(pin)) cg.add(var.set_flow_control_pin(pin))
if config[CONF_ROLE] == "client":
cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME])) cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME]))
cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME])) cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME]))
cg.add(var.set_disable_crc(config[CONF_DISABLE_CRC]))
def modbus_device_schema(default_address): def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"):
hub_type = ModbusClient if role == "client" else ModbusServer
schema = { schema = {
cv.GenerateID(CONF_MODBUS_ID): cv.use_id(Modbus), cv.GenerateID(CONF_MODBUS_ID): cv.use_id(hub_type),
} }
if default_address is None: if default_address is None:
schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t
@@ -98,8 +116,18 @@ def final_validate_modbus_device(
) )
async def register_modbus_device(var, config): async def register_modbus_client_device(var, config):
parent = await cg.get_variable(config[CONF_MODBUS_ID])
cg.add(var.set_parent(parent))
cg.add(var.set_address(config[CONF_ADDRESS]))
async def register_modbus_server_device(var, config):
parent = await cg.get_variable(config[CONF_MODBUS_ID]) parent = await cg.get_variable(config[CONF_MODBUS_ID])
cg.add(var.set_parent(parent)) cg.add(var.set_parent(parent))
cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(var.set_address(config[CONF_ADDRESS]))
cg.add(parent.register_device(var)) cg.add(parent.register_device(var))
async def register_modbus_device(var, config):
return await register_modbus_client_device(var, config)

View File

@@ -37,9 +37,36 @@ void Modbus::setup() {
} }
void Modbus::loop() { void Modbus::loop() {
// First process all available incoming data. // Receive any available bytes from UART
this->receive_and_parse_modbus_bytes_(); this->receive_bytes_();
// Parse bytes into frames and process them
this->parse_modbus_frames();
}
void ModbusClientHub::loop() {
// Call base class to receive bytes and parse frames
this->Modbus::loop();
// If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response
if (this->waiting_for_response_.has_value()) {
ModbusDeviceCommand &wfr = this->waiting_for_response_.value();
uint8_t expected_address = wfr.frame.data.get()[0];
if (this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ &&
(this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) {
ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address,
this->last_receive_check_ - this->last_send_);
if (wfr.device)
wfr.device->on_modbus_no_response();
this->waiting_for_response_.reset();
}
}
// If there's no response pending and there's commands in the buffer
this->send_next_frame_();
}
bool Modbus::timeout_() {
// If the response frame is finished (including interframe delay) - we 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 // 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 // when the buffer is filling the back half of the response
@@ -47,250 +74,307 @@ void Modbus::loop() {
(uint16_t) this->frame_delay_ms_, (uint16_t) this->frame_delay_ms_,
(uint16_t) (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold() ? this->long_rx_buffer_delay_ms_ (uint16_t) (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold() ? this->long_rx_buffer_delay_ms_
: 0)); : 0));
return this->last_receive_check_ - this->last_modbus_byte_ > timeout;
}
int32_t Modbus::tx_delay_remaining() {
// We use millis() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps // We use millis() 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 // 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 millis() and last_modbus_byte_ is updated inside our loop // If we use a cached value in place of millis() 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 // 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 // So in this component we don't use any cached timestamp values to avoid these annoying bugs
if (millis() - this->last_modbus_byte_ > timeout) { const uint32_t now = millis();
this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); 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_))});
}
// If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response int32_t ModbusClientHub::tx_delay_remaining() {
if (this->waiting_for_response_ != 0 && const uint32_t now = millis();
millis() - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && return std::max({(int32_t) 0,
(this->rx_buffer_.empty() || this->rx_buffer_[0] != this->waiting_for_response_)) { (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ -
ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", (now - this->last_send_)),
this->waiting_for_response_, millis() - this->last_send_); (int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))});
this->waiting_for_response_ = 0;
}
// If there's no response pending and there's commands in the buffer
this->send_next_frame_();
} }
bool Modbus::tx_blocked() { bool Modbus::tx_blocked() {
const uint32_t now = millis(); // We block transmission in any of these cases:
// We block transmission in any of these case:
// 1. There are bytes in the UART Rx buffer // 1. There are bytes in the UART Rx buffer
// 2. There are bytes in our Rx buffer // 2. There are bytes in our Rx buffer
// 3. We're waiting for a response // 3. The last sent byte isn't more than tx_delay ms ago (i.e. wait to tell receivers that our previous Tx is done)
// 4. The last sent byte isn't more than frame_delay ms ago (i.e. wait to tell receivers that our previous Tx is done) // 4. The last received byte isn't more than tx_delay ms ago (i.e. wait to be sure there isn't more Rx coming)
// 5. The last received byte isn't more than frame_delay ms ago (i.e. wait to be sure there isn't more Rx coming) // N.B. We allow a small delay (MODBUS_TX_MAX_DELAY_MS) to avoid looping on small delays. This gets handled by
// 6. If we're a client - also wait for the turnaround delay, to give the servers time to process the previous message // send_frame_.
return this->available() || !this->rx_buffer_.empty() || (this->waiting_for_response_ != 0) || return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_MS;
(now - this->last_send_ < this->last_send_tx_offset_ + this->frame_delay_ms_ +
(this->role == ModbusRole::CLIENT ? this->turnaround_delay_ms_ : 0)) ||
(now - this->last_modbus_byte_ <
this->frame_delay_ms_ + (this->role == ModbusRole::CLIENT ? this->turnaround_delay_ms_ : 0));
} }
bool Modbus::tx_buffer_empty() { return this->tx_buffer_.empty(); } bool ModbusClientHub::tx_blocked() {
// We block transmission in any of these case:
// 1. We're waiting for a response
// 2. Any of the base class tx_blocked conditions
return (this->waiting_for_response_.has_value()) || this->Modbus::tx_blocked();
}
void Modbus::receive_and_parse_modbus_bytes_() { bool ModbusClientHub::tx_buffer_empty() { return this->tx_buffer_.empty(); }
// Read all available bytes in batches to reduce UART call overhead.
size_t avail = this->available(); void Modbus::receive_bytes_() {
uint8_t buf[64]; this->last_receive_check_ = millis();
while (avail > 0) { size_t bytes = this->available();
size_t to_read = std::min(avail, sizeof(buf));
if (!this->read_array(buf, to_read)) { if (bytes) {
break; size_t buffer_size = this->rx_buffer_.size();
this->last_modbus_byte_ = 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 (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_);
}
}
}
void ModbusClientHub::parse_modbus_frames() {
if (!this->rx_buffer_.empty()) {
size_t size;
do {
size = this->rx_buffer_.size();
if (!this->parse_modbus_server_frame_())
this->clear_rx_buffer_(LOG_STR("parse failed"), true);
} while (!this->rx_buffer_.empty() && size > this->rx_buffer_.size());
if (this->timeout_())
this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true);
}
}
void ModbusServerHub::parse_modbus_frames() {
while (!this->rx_buffer_.empty()) {
size_t size = this->rx_buffer_.size();
ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size);
bool retry_as_client = false;
if (this->expecting_peer_response_ != 0) {
if (!this->parse_modbus_server_frame_()) {
ESP_LOGV(TAG, "Stop expecting peer response from %" PRIu8 " due to parse failure, and retry parse",
this->expecting_peer_response_);
this->expecting_peer_response_ = 0;
retry_as_client = true;
} else if (this->timeout_() && size == this->rx_buffer_.size()) {
// If we timed out and the above parse attempt did not consume data, stop expecting a response
ESP_LOGV(TAG,
"Stop expecting peer response from %" PRIu8 " due to timeout after partial response, and retry parse",
this->expecting_peer_response_);
this->expecting_peer_response_ = 0;
retry_as_client = true;
} }
avail -= to_read;
for (size_t i = 0; i < to_read; i++) {
if (this->rx_buffer_.empty()) {
ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) %" PRIu32 "ms after last send", buf[i], buf[i],
millis() - this->last_send_);
} else { } else {
ESP_LOGVV(TAG, "Received byte %" PRIu8 " (0X%x) %" PRIu32 "ms after last send", buf[i], buf[i], if (!this->parse_modbus_client_frame_())
millis() - this->last_send_);
}
// If the bytes in the rx buffer do not parse, clear out the buffer
if (!this->parse_modbus_byte_(buf[i])) {
this->clear_rx_buffer_(LOG_STR("parse failed"), true); this->clear_rx_buffer_(LOG_STR("parse failed"), true);
} }
this->last_modbus_byte_ = millis(); // Stop if the buffer didn't shrink (no frame consumed) and no mode switch triggered a retry
if (!retry_as_client && size <= this->rx_buffer_.size())
break;
}
if (this->timeout_())
this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true);
}
uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const {
// Custom functions could be any length - we have to rely on the CRC to determine completeness.
// If a CRC match is never found, the buffer will eventually overflow and be cleared.
const uint8_t *raw = &this->rx_buffer_[0];
const size_t size = this->rx_buffer_.size();
for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) {
if (crc16(raw, len) == 0)
return len;
}
return 0;
}
bool Modbus::parse_modbus_server_frame_() {
size_t size = this->rx_buffer_.size();
uint16_t frame_length = helpers::server_frame_length(this->rx_buffer_.data(), this->rx_buffer_.size());
if (size < frame_length)
return true;
uint8_t address = this->rx_buffer_[0];
uint8_t function_code = this->rx_buffer_[1];
if (helpers::is_function_code_custom(function_code)) {
frame_length = this->find_custom_frame_end_(frame_length);
if (frame_length == 0)
return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size
ESP_LOGD(TAG, "User-defined function %02X found", function_code);
} else {
if (crc16(&this->rx_buffer_[0], frame_length) != 0)
return false;
}
// Process before clearing: process_modbus_server_frame (receiving a response or peer message) never sends a reply
// synchronously. We can safely point directly into rx_buffer_ and avoid a copy.
uint8_t data_offset = helpers::server_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size());
const uint8_t *data = this->rx_buffer_.data() + data_offset;
uint16_t data_len = frame_length - 2 - data_offset;
this->process_modbus_server_frame(address, function_code, data, data_len);
this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length);
return true;
}
bool ModbusServerHub::parse_modbus_client_frame_() {
size_t size = this->rx_buffer_.size();
uint16_t frame_length = helpers::client_frame_length(this->rx_buffer_.data(), this->rx_buffer_.size());
if (size < frame_length)
return true;
uint8_t address = this->rx_buffer_[0];
uint8_t function_code = this->rx_buffer_[1];
if (helpers::is_function_code_custom(function_code)) {
frame_length = this->find_custom_frame_end_(frame_length);
if (frame_length == 0)
return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size
ESP_LOGD(TAG, "User-defined function %02X found", function_code);
} else {
if (crc16(&this->rx_buffer_[0], frame_length) != 0)
return false;
}
// Clear before processing: process_modbus_client_frame_ dispatches to a server device which sends
// a response immediately. We need to clear the rx buffer first so the response doesn't snag tx_blocked.
// This requires copying the frame data to a local buffer beforehand.
uint8_t data_offset = helpers::client_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size());
uint16_t data_len = frame_length - 2 - data_offset;
uint8_t data[MAX_FRAME_SIZE] = {};
std::memcpy(data, this->rx_buffer_.data() + data_offset, data_len);
this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length);
this->process_modbus_client_frame_(address, function_code, data, data_len);
return true;
}
void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data,
uint16_t len) {
if (!this->waiting_for_response_.has_value()) {
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_);
return;
} else { // We are waiting for a response
// Check if the response matches the expected address and function code
ModbusDeviceCommand &wfr = this->waiting_for_response_.value();
uint8_t expected_address = wfr.frame.data.get()[0];
uint8_t expected_function_code = wfr.frame.data.get()[1];
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",
address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code,
this->last_modbus_byte_ - this->last_send_);
// Invalidate the waiting device so it won't process this response.
if (wfr.device)
wfr.device->on_modbus_no_response();
wfr.interrupted = true;
wfr.device = nullptr;
return;
}
if (wfr.interrupted) {
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_);
return;
} else { // We have a valid device waiting for this response
ModbusClientDevice *device = wfr.device;
this->waiting_for_response_.reset();
// Is it an error response?
if (helpers::is_function_code_exception(function_code)) {
uint8_t exception = len > 0 ? data[0] : 0;
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_);
if (device)
device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception);
} else if (device) { // Not an error response
// on_modbus_data is existing public API taking const std::vector<uint8_t>&
device->on_modbus_data(std::vector<uint8_t>(data, data + len));
} else { // Not an error response, but no device to respond to
ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send",
address, this->last_modbus_byte_ - this->last_send_);
}
} }
} }
} }
bool Modbus::parse_modbus_byte_(uint8_t byte) { void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *, uint16_t) {
size_t at = this->rx_buffer_.size(); for (auto *device : this->devices_) {
this->rx_buffer_.push_back(byte); if (device->address_ == address) {
const uint8_t *raw = &this->rx_buffer_[0]; ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address);
}
// Byte 0: modbus address (match all) }
if (at == 0)
return true;
// Byte 1: function code
if (at == 1)
return true;
// Byte 2: Size (with modbus rtu function code 4/3)
// See also https://en.wikipedia.org/wiki/Modbus
if (at == 2)
return true;
uint8_t address = raw[0];
uint8_t function_code = raw[1];
uint8_t data_len = raw[2];
uint8_t data_offset = 3;
// Per https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf Ch 5 User-Defined function codes
if (((function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_1_INIT) &&
(function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_1_END)) ||
((function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT) &&
(function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END))) {
// Handle user-defined function, since we don't know how big this ought to be,
// ideally we should delegate the entire length detection to whatever handler is
// installed, but wait, there is the CRC, and if we get a hit there is a good
// chance that this is a complete message ... admittedly there is a small chance is
// isn't but that is quite small given the purpose of the CRC in the first place
data_len = at - 2;
data_offset = 1;
uint16_t computed_crc = crc16(raw, data_offset + data_len);
uint16_t remote_crc = uint16_t(raw[data_offset + data_len]) | (uint16_t(raw[data_offset + data_len + 1]) << 8);
if (computed_crc != remote_crc)
return true;
ESP_LOGD(TAG, "User-defined function %02X found", function_code);
if (this->expecting_peer_response_ == address) {
ESP_LOGV(TAG, "Expected response from peer %" PRIu8 " received", address);
} else { } else {
// data starts at 2 and length is 4 for read registers commands ESP_LOGV(TAG, "Unexpected response from peer %" PRIu8 " received", address);
if (this->role == ModbusRole::SERVER) {
if (function_code == ModbusFunctionCode::READ_COILS ||
function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS ||
function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS ||
function_code == ModbusFunctionCode::READ_INPUT_REGISTERS ||
function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) {
data_offset = 2;
data_len = 4;
} else if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
if (at < 6) {
return true;
}
data_offset = 2;
// starting address (2 bytes) + quantity of registers (2 bytes) + byte count itself (1 byte) + actual byte count
data_len = 2 + 2 + 1 + raw[6];
}
} else {
// the response for write command mirrors the requests and data starts at offset 2 instead of 3 for read commands
if (function_code == ModbusFunctionCode::WRITE_SINGLE_COIL ||
function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
data_offset = 2;
data_len = 4;
}
} }
// Error ( msb indicates error ) // This always resets, even if the address doesn't match.
// response format: Byte[0] = device address, Byte[1] function code | 0x80 , Byte[2] exception code, Byte[3-4] crc // If an unexpected response is received, we can't trust that a correct response will follow (it shouldn't).
if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) { this->expecting_peer_response_ = 0;
data_offset = 2; }
data_len = 1;
}
// Byte data_offset..data_offset+data_len-1: Data void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data,
if (at < data_offset + data_len) uint16_t len) {
return true;
// Byte 3+data_len: CRC_LO (over all bytes)
if (at == data_offset + data_len)
return true;
// Byte data_offset+len+1: CRC_HI (over all bytes)
uint16_t computed_crc = crc16(raw, data_offset + data_len);
uint16_t remote_crc = uint16_t(raw[data_offset + data_len]) | (uint16_t(raw[data_offset + data_len + 1]) << 8);
if (computed_crc != remote_crc) {
if (this->disable_crc_) {
ESP_LOGD(TAG, "CRC check failed %" PRIu32 "ms after last send; ignoring", millis() - this->last_send_);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGVV(TAG, " (%02X != %02X) %s", computed_crc, remote_crc,
format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_.size()));
} else {
ESP_LOGW(TAG, "CRC check failed %" PRIu32 "ms after last send", millis() - this->last_send_);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGVV(TAG, " (%02X != %02X) %s", computed_crc, remote_crc,
format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_.size()));
return false;
}
}
}
std::vector<uint8_t> data(this->rx_buffer_.begin() + data_offset, this->rx_buffer_.begin() + data_offset + data_len);
bool found = false; bool found = false;
for (auto *device : this->devices_) { for (auto *device : this->devices_) {
if (device->address_ == address) { if (device->address_ == address) {
found = true; found = true;
if (this->role == ModbusRole::SERVER) {
if (function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || if (static_cast<ModbusFunctionCode>(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS ||
function_code == ModbusFunctionCode::READ_INPUT_REGISTERS) { static_cast<ModbusFunctionCode>(function_code) == ModbusFunctionCode::READ_INPUT_REGISTERS) {
device->on_modbus_read_registers(function_code, uint16_t(data[1]) | (uint16_t(data[0]) << 8), device->on_modbus_read_registers(function_code, helpers::get_data<uint16_t>(data, 0),
uint16_t(data[3]) | (uint16_t(data[2]) << 8)); helpers::get_data<uint16_t>(data, 2));
} else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || } else if (static_cast<ModbusFunctionCode>(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { static_cast<ModbusFunctionCode>(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
device->on_modbus_write_registers(function_code, data); device->on_modbus_write_registers(function_code, std::vector<uint8_t>(data, data + len));
}
} else { // We're a client
// Is it an error response?
if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) {
uint8_t exception = raw[2];
ESP_LOGW(TAG,
"Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32
"ms after last send",
function_code, exception, address, millis() - this->last_send_);
if (this->waiting_for_response_ == address) {
device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception);
} else { } else {
// Ignore modbus exception not related to a pending command ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code);
ESP_LOGD(TAG, "Ignoring error - not expecting a response from %" PRIu8 "", address); device->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION);
}
} else { // Not an error response
if (this->waiting_for_response_ == address) {
device->on_modbus_data(data);
} else {
// Ignore modbus response not related to a pending command
ESP_LOGW(TAG, "Ignoring response - not expecting a response from %" PRIu8 ", %" PRIu32 "ms after last send",
address, millis() - this->last_send_);
}
}
} }
} }
} }
if (!found && this->role == ModbusRole::CLIENT) { if (!found) {
ESP_LOGW(TAG, "Got frame from unknown address %" PRIu8 ", %" PRIu32 "ms after last send", address, this->expecting_peer_response_ = address;
millis() - this->last_send_); ESP_LOGV(TAG, "Request to peer %" PRIu8 " received", address);
} }
this->clear_rx_buffer_(LOG_STR("parse succeeded"));
if (this->waiting_for_response_ == address)
this->waiting_for_response_ = 0;
return true;
} }
void Modbus::send_next_frame_() { bool Modbus::send_frame_(const ModbusFrame &frame) {
if (this->tx_buffer_.empty()) if (this->tx_blocked()) {
return; ESP_LOGE(TAG, "Attempted to send while transmission blocked");
return false;
}
if (frame.size > MAX_FRAME_SIZE) {
ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %" PRIu16 " bytes", MAX_FRAME_SIZE);
return false;
}
if (this->tx_blocked()) const int32_t tx_delay_remaining = this->tx_delay_remaining();
return; if (tx_delay_remaining > 0) {
delay(tx_delay_remaining);
const ModbusDeviceCommand &frame = this->tx_buffer_.front();
if (this->role == ModbusRole::CLIENT) {
this->waiting_for_response_ = frame.data.get()[0];
} }
if (this->flow_control_pin_ != nullptr) { if (this->flow_control_pin_ != nullptr) {
@@ -304,123 +388,190 @@ void Modbus::send_next_frame_() {
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 * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1;
} }
uint32_t now = millis();
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif #endif
ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send", format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send, %" PRIu32 "ms after last receive",
millis() - this->last_send_); format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_,
this->last_send_ = millis(); now - this->last_modbus_byte_);
this->last_send_ = now;
return true;
}
void ModbusClientHub::send_next_frame_() {
if (this->tx_buffer_.empty()) {
return;
}
if (this->tx_blocked()) {
return;
}
ModbusDeviceCommand &command = this->tx_buffer_.front();
if (this->send_frame_(command.frame)) {
this->waiting_for_response_ = std::move(command);
} else {
if (command.device)
command.device->on_modbus_not_sent();
}
this->tx_buffer_.pop_front(); this->tx_buffer_.pop_front();
if (!this->tx_buffer_.empty()) { if (!this->tx_buffer_.empty()) {
ESP_LOGV(TAG, "Write queue contains %zu items.", this->tx_buffer_.size()); ESP_LOGV(TAG, "Write queue contains %zu items.", this->tx_buffer_.size());
} }
} }
void Modbus::dump_config() { void ModbusClientHub::dump_config() {
ESP_LOGCONFIG(TAG, ESP_LOGCONFIG(TAG,
"Modbus:\n" "Modbus:\n"
" Send Wait Time: %d ms\n" " Send Wait Time: %" PRIu16 " ms\n"
" Turnaround Time: %d ms\n" " Turnaround Time: %" PRIu16 " ms\n"
" Frame Delay: %d ms\n" " Frame Delay: %" PRIu16 " ms\n"
" Long Rx Buffer Delay: %d ms\n" " Long Rx Buffer Delay: %" PRIu16 " ms",
" CRC Disabled: %s",
this->send_wait_time_, this->turnaround_delay_ms_, this->frame_delay_ms_, this->send_wait_time_, this->turnaround_delay_ms_, this->frame_delay_ms_,
this->long_rx_buffer_delay_ms_, YESNO(this->disable_crc_)); this->long_rx_buffer_delay_ms_);
LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); 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_);
LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_);
}
float Modbus::get_setup_priority() const { float Modbus::get_setup_priority() const {
// After UART bus // After UART bus
return setup_priority::BUS - 1.0f; return setup_priority::BUS - 1.0f;
} }
void Modbus::send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, void ModbusServerHub::send(uint8_t address, uint8_t function_code, const std::vector<uint8_t> &payload) {
uint8_t payload_len, const uint8_t *payload) { const uint16_t len = static_cast<uint16_t>(2 + payload.size());
static const size_t MAX_VALUES = 128; if (len > MAX_RAW_SIZE) {
ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len);
// Only check max number of registers for standard function codes
// Some devices use non standard codes like 0x43
if (number_of_entities > MAX_VALUES && function_code <= ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
ESP_LOGE(TAG, "send too many values %d max=%zu", number_of_entities, MAX_VALUES);
return; return;
} }
uint8_t raw_frame[MAX_RAW_SIZE];
uint8_t data[MAX_FRAME_SIZE]; raw_frame[0] = address;
size_t pos = 0; raw_frame[1] = function_code;
std::memcpy(raw_frame + 2, payload.data(), payload.size());
data[pos++] = address; this->send_raw_(raw_frame, len);
data[pos++] = function_code;
if (this->role == ModbusRole::CLIENT) {
data[pos++] = start_address >> 8;
data[pos++] = start_address >> 0;
if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL &&
function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) {
data[pos++] = number_of_entities >> 8;
data[pos++] = number_of_entities >> 0;
}
}
if (payload != nullptr) {
if (this->role == ModbusRole::SERVER || function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { // Write multiple
data[pos++] = payload_len; // Byte count is required for write
} else {
payload_len = 2; // Write single register or coil
}
if (payload_len + pos + 2 > MAX_FRAME_SIZE) { // Check if payload fits (accounting for CRC)
ESP_LOGE(TAG, "Payload too large to send: %d bytes", payload_len);
return;
}
for (int i = 0; i < payload_len; i++) {
data[pos++] = payload[i];
}
}
this->queue_raw_(data, pos);
} }
// Helper function for lambdas // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload.
// Send raw command. Except CRC everything must be contained in payload void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) {
void Modbus::send_raw(const std::vector<uint8_t> &payload) { if (pdu_len == 0) {
if (payload.empty()) { if (device)
device->on_modbus_not_sent();
return; return;
} }
// Frame size: payload + CRC(2)
if (payload.size() + 2 > MAX_FRAME_SIZE) {
ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %d bytes", MAX_FRAME_SIZE);
return;
}
// Use stack buffer - Modbus frames are small and bounded
uint8_t data[MAX_FRAME_SIZE];
std::memcpy(data, payload.data(), payload.size());
this->queue_raw_(data, payload.size());
}
// Assume data and length is valid and append CRC, then queue for sending. Used internally to avoid unnecessary copying
// of data into vectors
void Modbus::queue_raw_(const uint8_t *data, uint16_t len) {
if (this->tx_buffer_.size() < MODBUS_TX_BUFFER_SIZE) { if (this->tx_buffer_.size() < MODBUS_TX_BUFFER_SIZE) {
this->tx_buffer_.emplace_back(data, len); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len));
this->tx_buffer_.emplace_back(device, address, pdu, pdu_len);
} else { } else {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif #endif
ESP_LOGE(TAG, "Write buffer full, dropped: %s", format_hex_pretty_to(hex_buf, data, len)); ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len));
if (device)
device->on_modbus_not_sent();
} }
} }
void Modbus::clear_rx_buffer_(const LogString *reason, bool warn) { void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) {
size_t at = this->rx_buffer_.size(); // Remove any pending commands for this address from the tx buffer
if (at > 0) { auto &tx_buffer = this->tx_buffer_;
tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(),
[address](const ModbusDeviceCommand &cmd) { return cmd.frame.data[0] == address; }),
tx_buffer.end());
if (clear_sent && this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) {
if (this->waiting_for_response_.value().frame.data[0] == address) {
ESP_LOGV(TAG, "Clearing waiting for response for address %" PRIu8, address);
// Invalidate the waiting device so it won't process a response.
this->waiting_for_response_.value().device = nullptr;
}
}
}
void ModbusClientHub::clear_tx_queue_for_device(ModbusClientDevice *device) {
// Remove any pending commands for this address from the tx buffer
auto &tx_buffer = this->tx_buffer_;
tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(),
[device](const ModbusDeviceCommand &cmd) { return cmd.device == device; }),
tx_buffer.end());
if (this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) {
if (this->waiting_for_response_.value().device == device) {
ESP_LOGV(TAG, "Clearing waiting for response");
// Invalidate the waiting device so it won't process a response.
this->waiting_for_response_.value().device = nullptr;
}
}
}
void ModbusClientHub::send_raw(const std::vector<uint8_t> &payload, ModbusClientDevice *device) {
if (payload.size() < 2) {
if (device)
device->on_modbus_not_sent();
return;
}
this->queue_raw_(payload[0], payload.data() + 1, static_cast<uint16_t>(payload.size() - 1), device);
}
// Send raw command for server replies immediately. Except CRC everything must be contained in payload
void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) {
if (len == 0) {
return;
}
if (len > MAX_RAW_SIZE) {
ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len);
return;
}
// In the rare case that the server is blocked (frame delay has not elapsed), we delay the send.
// This should only happen at low baud rates with long frame delays.
if (this->tx_blocked()) {
// Stash the raw payload in a single member buffer so the deferred callback can rebuild the frame
// without a heap allocation. Only one server reply is ever in flight, and the named timeout ensures
// only one deferred send is pending, so a single buffer is sufficient.
std::memcpy(this->deferred_payload_.data(), payload, len);
this->deferred_payload_len_ = len;
this->set_timeout("deferred_send", this->tx_delay_remaining(), [this]() {
ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1,
this->deferred_payload_len_ - 1);
this->send_frame_(frame);
});
} else {
ModbusFrame frame(payload[0], payload + 1, len - 1);
this->send_frame_(frame);
}
}
void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) {
size_t bytes = this->rx_buffer_.size();
if (bytes_to_clear > 0 && bytes >= bytes_to_clear)
bytes = bytes_to_clear;
if (bytes > 0) {
if (warn) { if (warn) {
ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", at, LOG_STR_ARG(reason), ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason),
millis() - this->last_send_); millis() - this->last_send_);
} else { } else {
ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", at, LOG_STR_ARG(reason), ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason),
millis() - this->last_send_); millis() - this->last_send_);
} }
if (bytes == this->rx_buffer_.size()) {
this->rx_buffer_.clear(); this->rx_buffer_.clear();
} else {
this->rx_buffer_.erase(this->rx_buffer_.begin(), this->rx_buffer_.begin() + bytes);
}
} }
} }

View File

@@ -4,33 +4,32 @@
#include "esphome/components/uart/uart.h" #include "esphome/components/uart/uart.h"
#include "esphome/components/modbus/modbus_definitions.h" #include "esphome/components/modbus/modbus_definitions.h"
#include "esphome/components/modbus/modbus_helpers.h"
#include <array>
#include <cstring> #include <cstring>
#include <memory> #include <memory>
#include <vector> #include <vector>
#include <queue> #include <deque>
#include <optional>
namespace esphome::modbus { namespace esphome::modbus {
static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15; static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15;
static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5;
enum ModbusRole { struct ModbusFrame {
CLIENT,
SERVER,
};
class ModbusDevice;
struct ModbusDeviceCommand {
// Frame with exact-size allocation to avoid std::vector overhead // Frame with exact-size allocation to avoid std::vector overhead
std::unique_ptr<uint8_t[]> data; std::unique_ptr<uint8_t[]> data;
uint16_t size; // Modbus RTU max is 256 bytes uint16_t size; // Modbus RTU max is 256 bytes
ModbusDeviceCommand(const uint8_t *src, uint16_t len) : data(std::make_unique<uint8_t[]>(len + 2)), size(len + 2) { ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len)
std::memcpy(this->data.get(), src, len); : data(std::make_unique<uint8_t[]>(pdu_len + 3)), size(pdu_len + 3) {
auto crc = crc16(data.get(), len); data[0] = address;
data[len + 0] = crc >> 0; memcpy(data.get() + 1, pdu, pdu_len);
data[len + 1] = crc >> 8; auto crc = crc16(data.get(), pdu_len + 1);
data[pdu_len + 1] = crc >> 0;
data[pdu_len + 2] = crc >> 8;
} }
}; };
@@ -39,86 +38,197 @@ class Modbus : public uart::UARTDevice, public Component {
Modbus() = default; Modbus() = default;
void setup() override; void setup() override;
void loop() override; void loop() override;
void dump_config() override;
void register_device(ModbusDevice *device) { this->devices_.push_back(device); }
float get_setup_priority() const override; float get_setup_priority() const override;
bool tx_buffer_empty(); virtual bool tx_blocked();
bool tx_blocked();
void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities,
uint8_t payload_len = 0, const uint8_t *payload = nullptr);
void send_raw(const std::vector<uint8_t> &payload);
void set_role(ModbusRole role) { this->role = role; }
void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; } void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; }
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; }
void set_disable_crc(bool disable_crc) { this->disable_crc_ = disable_crc; }
ModbusRole role;
protected: protected:
bool parse_modbus_byte_(uint8_t byte); void receive_bytes_();
void receive_and_parse_modbus_bytes_(); bool timeout_();
void clear_rx_buffer_(const LogString *reason, bool warn = false); virtual int32_t tx_delay_remaining();
void send_next_frame_(); virtual void parse_modbus_frames() = 0;
void queue_raw_(const uint8_t *data, uint16_t len); bool parse_modbus_server_frame_();
virtual void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data,
uint16_t len) = 0;
void clear_rx_buffer_(const LogString *reason, bool warn = false, size_t bytes_to_clear = 0);
bool send_frame_(const ModbusFrame &frame);
// Scans forward from min_length to find a frame boundary by CRC match for custom function codes.
// Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE.
uint16_t find_custom_frame_end_(uint16_t min_length) const;
uint32_t last_modbus_byte_{0}; uint32_t last_modbus_byte_{0};
uint32_t last_receive_check_{0};
uint32_t last_send_{0}; uint32_t last_send_{0};
uint32_t last_send_tx_offset_{0}; uint32_t last_send_tx_offset_{0};
uint16_t frame_delay_ms_{5}; uint16_t frame_delay_ms_{5};
uint16_t long_rx_buffer_delay_ms_{0}; uint16_t long_rx_buffer_delay_ms_{0};
uint16_t send_wait_time_{250};
uint16_t turnaround_delay_ms_{100};
uint8_t waiting_for_response_{0};
bool disable_crc_{false};
GPIOPin *flow_control_pin_{nullptr}; GPIOPin *flow_control_pin_{nullptr};
std::vector<uint8_t> rx_buffer_; std::vector<uint8_t> rx_buffer_;
std::vector<ModbusDevice *> devices_; };
class ModbusClientDevice;
class ModbusServerDevice;
struct ModbusDeviceCommand {
ModbusClientDevice *device;
ModbusFrame frame;
bool interrupted{false};
ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, const uint8_t *src, uint16_t len)
: device(device), frame(address, src, len) {}
};
class ModbusClientHub : public Modbus {
public:
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; }
bool tx_buffer_empty();
bool tx_blocked() override;
ESPDEPRECATED("Use send_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0")
void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities,
uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) {
this->send_pdu(address,
helpers::create_client_pdu((ModbusFunctionCode) function_code, start_address, number_of_entities,
payload, payload_len),
device);
};
void send_pdu(uint8_t address, const StaticVector<uint8_t, MAX_PDU_SIZE> &pdu, ModbusClientDevice *device = nullptr) {
this->queue_raw_(address, pdu.data(), pdu.size(), device);
}
void send_raw(const std::vector<uint8_t> &payload, ModbusClientDevice *device = nullptr);
void clear_tx_queue_for_address(uint8_t address, bool clear_sent = true);
void clear_tx_queue_for_device(ModbusClientDevice *device);
protected:
int32_t tx_delay_remaining() override;
void parse_modbus_frames() override;
// Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here.
void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override;
void send_next_frame_();
void queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device = nullptr);
uint16_t send_wait_time_{2000};
uint16_t turnaround_delay_ms_{0};
std::optional<ModbusDeviceCommand> waiting_for_response_;
// std::deque is appropriate here since we need a FIFO buffer, and we can't know ahead of time how many // std::deque is appropriate here since we need a FIFO buffer, and we can't know ahead of time how many
// requests will be queued. Each modbus component may queue multiple requests, and the sequence of scheduling // requests will be queued. Each modbus component may queue multiple requests, and the sequence of scheduling
// may change at run time. // may change at run time.
std::deque<ModbusDeviceCommand> tx_buffer_; std::deque<ModbusDeviceCommand> tx_buffer_;
}; };
class ModbusDevice { class ModbusServerHub : public Modbus {
public: public:
void set_parent(Modbus *parent) { parent_ = parent; } ModbusServerHub() = default;
void set_address(uint8_t address) { address_ = address; } void dump_config() override;
virtual void on_modbus_data(const std::vector<uint8_t> &data) = 0; void send(uint8_t address, uint8_t function_code, const std::vector<uint8_t> &payload);
virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} ESPDEPRECATED("Use ModbusServerDevice::send_raw instead. Removed in 2026.10.0", "2026.4.0")
virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){}; void send_raw(const std::vector<uint8_t> &payload) {
virtual void on_modbus_write_registers(uint8_t function_code, const std::vector<uint8_t> &data){}; this->send_raw_(payload.data(), static_cast<uint16_t>(payload.size()));
void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, };
const uint8_t *payload = nullptr) { void register_device(ModbusServerDevice *device) { this->devices_.push_back(device); }
this->parent_->send(this->address_, function, start_address, number_of_entities, payload_len, payload);
}
void send_raw(const std::vector<uint8_t> &payload) { this->parent_->send_raw(payload); }
void send_error(uint8_t function_code, ModbusExceptionCode exception_code) {
std::vector<uint8_t> error_response;
error_response.reserve(3);
error_response.push_back(this->address_);
error_response.push_back(function_code | FUNCTION_CODE_EXCEPTION_MASK);
error_response.push_back(static_cast<uint8_t>(exception_code));
this->send_raw(error_response);
}
// If more than one device is connected block sending a new command before a response is received
ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0")
bool waiting_for_response() { return !ready_for_immediate_send(); }
bool ready_for_immediate_send() { return parent_->tx_buffer_empty() && !parent_->tx_blocked(); }
protected: protected:
friend Modbus; friend class ModbusServerDevice;
Modbus *parent_; void parse_modbus_frames() override;
uint8_t address_; bool parse_modbus_client_frame_();
// Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here.
void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override;
void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len);
void send_raw_(const uint8_t *payload, uint16_t len);
uint8_t expecting_peer_response_{0};
std::vector<ModbusServerDevice *> devices_;
// Holds the raw payload of a single reply deferred for sending when tx was blocked at send time.
// Only one server reply can be in flight at once, so a single fixed buffer avoids heap allocation.
std::array<uint8_t, MAX_RAW_SIZE> deferred_payload_;
uint16_t deferred_payload_len_{0};
};
class ModbusClientDevice {
public:
ModbusClientDevice() = default;
ModbusClientDevice(ModbusClientHub *parent, uint8_t address) : parent_(parent), address_(address) {}
virtual ~ModbusClientDevice() {
if (this->parent_ != nullptr)
this->clear_tx_queue_for_device();
}
ModbusClientDevice(const ModbusClientDevice &) = delete;
ModbusClientDevice &operator=(const ModbusClientDevice &) = delete;
ModbusClientDevice(ModbusClientDevice &&) = delete;
ModbusClientDevice &operator=(ModbusClientDevice &&) = delete;
void set_parent(ModbusClientHub *parent) { this->parent_ = parent; }
void set_address(uint8_t address) { this->address_ = address; }
virtual void on_modbus_data(const std::vector<uint8_t> &data) {}
virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {}
virtual void on_modbus_not_sent() {}
virtual void on_modbus_no_response() {}
void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0,
const uint8_t *payload = nullptr) {
this->parent_->send_pdu(this->address_,
helpers::create_client_pdu((ModbusFunctionCode) function, start_address, number_of_entities,
payload, payload_len),
this);
}
void send_pdu(const StaticVector<uint8_t, MAX_PDU_SIZE> &pdu) { this->parent_->send_pdu(this->address_, pdu, this); }
void send_raw(const std::vector<uint8_t> &payload) { this->parent_->send_raw(payload, this); }
inline void clear_tx_queue_for_address(bool clear_sent = true) {
this->parent_->clear_tx_queue_for_address(this->address_, clear_sent);
}
inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); }
// If more than one device is connected block sending a new command before a response is received
ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0")
bool waiting_for_response() { return !this->ready_for_immediate_send(); }
bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); }
protected:
ModbusClientHub *parent_{nullptr};
uint8_t address_{0};
};
// This is for compatibility with external components using the former class name
using ModbusDevice = ModbusClientDevice;
class ModbusServerDevice {
public:
ModbusServerDevice() = default;
ModbusServerDevice(ModbusServerHub *parent, uint8_t address) : parent_(parent), address_(address) {}
virtual ~ModbusServerDevice() = default;
ModbusServerDevice(const ModbusServerDevice &) = delete;
ModbusServerDevice &operator=(const ModbusServerDevice &) = delete;
ModbusServerDevice(ModbusServerDevice &&) = delete;
ModbusServerDevice &operator=(ModbusServerDevice &&) = delete;
void set_parent(ModbusServerHub *parent) { this->parent_ = parent; }
void set_address(uint8_t address) { this->address_ = address; }
virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){};
virtual void on_modbus_write_registers(uint8_t function_code, const std::vector<uint8_t> &data){};
void send(uint8_t function, const std::vector<uint8_t> &payload) {
this->parent_->send(this->address_, function, payload);
}
void send_raw(const std::vector<uint8_t> &payload) {
this->parent_->send_raw_(payload.data(), static_cast<uint16_t>(payload.size()));
}
void send_error(uint8_t function_code, ModbusExceptionCode exception_code) {
uint8_t error_response[3] = {this->address_, uint8_t(function_code | FUNCTION_CODE_EXCEPTION_MASK),
static_cast<uint8_t>(exception_code)};
this->parent_->send_raw_(error_response, 3);
}
protected:
friend ModbusServerHub;
ModbusServerHub *parent_{nullptr};
uint8_t address_{0};
}; };
} // namespace esphome::modbus } // namespace esphome::modbus

View File

@@ -14,7 +14,8 @@ const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT = 100; // 0x64
const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E
enum class ModbusFunctionCode : uint8_t { enum class ModbusFunctionCode : uint8_t {
CUSTOM = 0x00, INVALID = 0x00, // 0x00 is not a valid function code (even for custom functions).
CUSTOM = 0x00, // The CUSTOM alias should be removed in future.
READ_COILS = 0x01, READ_COILS = 0x01,
READ_DISCRETE_INPUTS = 0x02, READ_DISCRETE_INPUTS = 0x02,
READ_HOLDING_REGISTERS = 0x03, READ_HOLDING_REGISTERS = 0x03,
@@ -35,19 +36,11 @@ enum class ModbusFunctionCode : uint8_t {
READ_FIFO_QUEUE = 0x18, // not implemented READ_FIFO_QUEUE = 0x18, // not implemented
}; };
/*Allow comparison operators between ModbusFunctionCode and uint8_t*/ /*Allow direct comparison operators between ModbusFunctionCode and uint8_t*/
inline bool operator==(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) == rhs; } inline bool operator==(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) == rhs; }
inline bool operator==(uint8_t lhs, ModbusFunctionCode rhs) { return lhs == static_cast<uint8_t>(rhs); } inline bool operator==(uint8_t lhs, ModbusFunctionCode rhs) { return lhs == static_cast<uint8_t>(rhs); }
inline bool operator!=(ModbusFunctionCode lhs, uint8_t rhs) { return !(static_cast<uint8_t>(lhs) == rhs); } inline bool operator!=(ModbusFunctionCode lhs, uint8_t rhs) { return !(static_cast<uint8_t>(lhs) == rhs); }
inline bool operator!=(uint8_t lhs, ModbusFunctionCode rhs) { return !(lhs == static_cast<uint8_t>(rhs)); } inline bool operator!=(uint8_t lhs, ModbusFunctionCode rhs) { return !(lhs == static_cast<uint8_t>(rhs)); }
inline bool operator<(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) < rhs; }
inline bool operator<(uint8_t lhs, ModbusFunctionCode rhs) { return lhs < static_cast<uint8_t>(rhs); }
inline bool operator<=(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) <= rhs; }
inline bool operator<=(uint8_t lhs, ModbusFunctionCode rhs) { return lhs <= static_cast<uint8_t>(rhs); }
inline bool operator>(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) > rhs; }
inline bool operator>(uint8_t lhs, ModbusFunctionCode rhs) { return lhs > static_cast<uint8_t>(rhs); }
inline bool operator>=(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) >= rhs; }
inline bool operator>=(uint8_t lhs, ModbusFunctionCode rhs) { return lhs >= static_cast<uint8_t>(rhs); }
// 4.3 MODBUS Data model // 4.3 MODBUS Data model
enum class ModbusRegisterType : uint8_t { enum class ModbusRegisterType : uint8_t {
@@ -75,12 +68,21 @@ enum class ModbusExceptionCode : uint8_t {
}; };
// 6.12 16 (0x10) Write Multiple registers: // 6.12 16 (0x10) Write Multiple registers:
const uint8_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B
// 6.1 01 (0x01) Read Coils
// 6.2 02 (0x02) Read Discrete Inputs
static constexpr uint16_t MAX_NUM_OF_COILS_TO_READ = 2000; // 0x7D0
static constexpr uint16_t MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000; // 0x7D0
// 6.3 03 (0x03) Read Holding Registers // 6.3 03 (0x03) Read Holding Registers
// 6.4 04 (0x04) Read Input Registers // 6.4 04 (0x04) Read Input Registers
const uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D static constexpr uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D
// Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2)
static constexpr uint16_t MIN_FRAME_SIZE = 4;
static constexpr uint16_t MAX_PDU_SIZE = 253; // Max PDU size is 256 - address(1) - CRC(2) = 253
static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 255 - CRC(2) = 254
static constexpr uint16_t MAX_FRAME_SIZE = 256; static constexpr uint16_t MAX_FRAME_SIZE = 256;
/// End of Modbus definitions /// End of Modbus definitions
} // namespace esphome::modbus } // namespace esphome::modbus

View File

@@ -1,10 +1,83 @@
#include "modbus_helpers.h" #include "modbus_helpers.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#include <algorithm>
namespace esphome::modbus::helpers { namespace esphome::modbus::helpers {
static const char *const TAG = "modbus_helpers"; static const char *const TAG = "modbus_helpers";
uint16_t server_frame_length(const uint8_t *frame, size_t size) {
if (size < 2)
return MIN_FRAME_SIZE;
if (is_function_code_exception(frame[1])) {
return 5; // address(1) + function(1) + exception(1) + CRC(2)
}
switch (static_cast<ModbusFunctionCode>(frame[1])) {
case ModbusFunctionCode::READ_COILS:
case ModbusFunctionCode::READ_DISCRETE_INPUTS:
case ModbusFunctionCode::READ_HOLDING_REGISTERS:
case ModbusFunctionCode::READ_INPUT_REGISTERS:
// address(1) + function(1) + byte count(1) + data + CRC(2)
return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0);
case ModbusFunctionCode::WRITE_SINGLE_COIL:
case ModbusFunctionCode::WRITE_SINGLE_REGISTER:
case ModbusFunctionCode::WRITE_MULTIPLE_COILS:
case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS:
return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2)
// Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions.
case ModbusFunctionCode::READ_FILE_RECORD:
case ModbusFunctionCode::WRITE_FILE_RECORD:
// address(1) + function(1) + byte count(1) + data + CRC(2)
return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0);
case ModbusFunctionCode::MASK_WRITE_REGISTER:
return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2)
case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
// address(1) + function(1) + byte count(1) + data + CRC(2)
return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0);
case ModbusFunctionCode::READ_FIFO_QUEUE:
// address(1) + function(1) + fifo address(2) CRC(2)
return 6;
default:
return MIN_FRAME_SIZE; // unknown length
}
}
uint16_t client_frame_length(const uint8_t *frame, size_t size) {
if (size < 2)
return MIN_FRAME_SIZE;
switch (static_cast<ModbusFunctionCode>(frame[1])) {
case ModbusFunctionCode::READ_COILS:
case ModbusFunctionCode::READ_DISCRETE_INPUTS:
case ModbusFunctionCode::READ_HOLDING_REGISTERS:
case ModbusFunctionCode::READ_INPUT_REGISTERS:
// address(1) + function(1) + start address(2) + quantity(2) + CRC(2)
case ModbusFunctionCode::WRITE_SINGLE_COIL:
case ModbusFunctionCode::WRITE_SINGLE_REGISTER:
return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2)
case ModbusFunctionCode::WRITE_MULTIPLE_COILS:
case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS:
// address(1) + function(1) + start address(2) + quantity(2) + byte count(1) + data + CRC(2)
return 9 + (size > 6 ? std::min(frame[6], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0);
// Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions.
case ModbusFunctionCode::READ_FILE_RECORD:
case ModbusFunctionCode::WRITE_FILE_RECORD:
// address(1) + function(1) + byte count(1) + data + CRC(2)
return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0);
case ModbusFunctionCode::MASK_WRITE_REGISTER:
return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2)
case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
// address(1) + function(1) + read start address(2) + read quantity(2) + write start address(2) +
// write quantity(2) + byte count(1) + data + CRC(2)
return 13 + (size > 10 ? std::min(frame[10], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0);
case ModbusFunctionCode::READ_FIFO_QUEUE:
// address(1) + function(1) + fifo address(2) CRC(2)
return 6;
default:
return MIN_FRAME_SIZE; // unknown length
}
}
static size_t required_payload_size(SensorValueType sensor_value_type) { static size_t required_payload_size(SensorValueType sensor_value_type) {
switch (sensor_value_type) { switch (sensor_value_type) {
case SensorValueType::U_WORD: case SensorValueType::U_WORD:
@@ -67,7 +140,7 @@ void number_to_payload(std::vector<uint16_t> &data, int64_t value, SensorValueTy
} }
int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sensor_value_type, uint8_t offset, int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sensor_value_type, uint8_t offset,
uint32_t bitmask) { uint32_t bitmask, bool *error_return) {
int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits
// Validate offset against the buffer for all types, including RAW/unsupported, so // Validate offset against the buffer for all types, including RAW/unsupported, so
@@ -75,6 +148,8 @@ int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sens
if (static_cast<size_t>(offset) > data.size()) { if (static_cast<size_t>(offset) > data.size()) {
ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast<unsigned int>(sensor_value_type), ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast<unsigned int>(sensor_value_type),
static_cast<unsigned int>(offset), data.size()); static_cast<unsigned int>(offset), data.size());
if (error_return)
*error_return = true;
return value; return value;
} }
@@ -87,6 +162,8 @@ int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sens
ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu",
static_cast<unsigned int>(sensor_value_type), static_cast<unsigned int>(offset), data.size(), static_cast<unsigned int>(sensor_value_type), static_cast<unsigned int>(offset), data.size(),
required_size); required_size);
if (error_return)
*error_return = true;
return value; return value;
} }
@@ -136,4 +213,102 @@ int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sens
} }
return value; return value;
} }
StaticVector<uint8_t, MAX_PDU_SIZE> create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address,
uint16_t number_of_entities, const uint8_t *values,
size_t values_len) {
if (is_function_code_read(static_cast<uint8_t>(function_code))) {
if (values != nullptr || values_len > 0) {
ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored",
static_cast<uint8_t>(function_code));
}
} else if (is_function_code_write(static_cast<uint8_t>(function_code))) {
if (values == nullptr || values_len == 0) {
ESP_LOGE(TAG, "No values provided for write function code %02X", static_cast<uint8_t>(function_code));
return {};
}
} else {
ESP_LOGE(TAG, "Unsupported function code %02X for client PDU creation", static_cast<uint8_t>(function_code));
return {};
}
if (number_of_entities == 0) {
ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast<uint8_t>(function_code));
return {};
}
switch (function_code) {
case ModbusFunctionCode::READ_COILS:
if (number_of_entities > MAX_NUM_OF_COILS_TO_READ) {
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum coils to read %u for function code %02X",
number_of_entities, MAX_NUM_OF_COILS_TO_READ, static_cast<uint8_t>(function_code));
return {};
}
break;
case ModbusFunctionCode::READ_DISCRETE_INPUTS:
if (number_of_entities > MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) {
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum discrete inputs to read %u for function code %02X",
number_of_entities, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, static_cast<uint8_t>(function_code));
return {};
}
break;
case ModbusFunctionCode::READ_HOLDING_REGISTERS:
case ModbusFunctionCode::READ_INPUT_REGISTERS:
if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_READ) {
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to read %u for function code %02X",
number_of_entities, MAX_NUM_OF_REGISTERS_TO_READ, static_cast<uint8_t>(function_code));
return {};
}
break;
case ModbusFunctionCode::WRITE_SINGLE_COIL:
case ModbusFunctionCode::WRITE_SINGLE_REGISTER:
break; // number_of_entities is ignored for single write, so no need to validate
case ModbusFunctionCode::WRITE_MULTIPLE_COILS:
case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS:
if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_WRITE) {
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to write %u for function code %02X",
number_of_entities, MAX_NUM_OF_REGISTERS_TO_WRITE, static_cast<uint8_t>(function_code));
return {};
}
break;
default:
ESP_LOGE(TAG, "Unsupported function code %u for client PDU creation", static_cast<unsigned int>(function_code));
return {};
}
StaticVector<uint8_t, MAX_PDU_SIZE> pdu;
pdu.push_back(static_cast<uint8_t>(function_code));
pdu.push_back(start_address >> 8);
pdu.push_back(start_address >> 0);
if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL &&
function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) {
pdu.push_back(number_of_entities >> 8);
pdu.push_back(number_of_entities >> 0);
}
if (is_function_code_write(static_cast<uint8_t>(function_code))) {
if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
// 6 bytes of overhead (fc + start_addr×2 + qty×2 + byte_count) leave MAX_PDU_SIZE-6 bytes for values
static constexpr size_t MAX_WRITE_MULTIPLE_VALUES_LEN = MAX_PDU_SIZE - 6;
if (values_len > MAX_WRITE_MULTIPLE_VALUES_LEN) {
ESP_LOGE(TAG, "values_len %zu exceeds PDU capacity %zu, dropping request", values_len,
MAX_WRITE_MULTIPLE_VALUES_LEN);
return {};
}
pdu.push_back(values_len); // Byte count is required for write multiple
for (size_t i = 0; i < values_len; i++)
pdu.push_back(values[i]);
} else {
// Write single register or coil (2 bytes)
if (values_len < 2) {
ESP_LOGE(TAG, "values_len %zu too small for write-single command (need 2), dropping request", values_len);
return {};
}
pdu.push_back(values[0]);
pdu.push_back(values[1]);
}
}
return pdu;
}
} // namespace esphome::modbus::helpers } // namespace esphome::modbus::helpers

View File

@@ -9,6 +9,58 @@
namespace esphome::modbus::helpers { namespace esphome::modbus::helpers {
inline bool is_function_code_read(uint8_t function_code) {
ModbusFunctionCode masked_function_code = static_cast<ModbusFunctionCode>(function_code & FUNCTION_CODE_MASK);
return masked_function_code == ModbusFunctionCode::READ_COILS ||
masked_function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS ||
masked_function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS ||
masked_function_code == ModbusFunctionCode::READ_INPUT_REGISTERS;
}
inline bool is_function_code_write(uint8_t function_code) {
ModbusFunctionCode masked_function_code = static_cast<ModbusFunctionCode>(function_code & FUNCTION_CODE_MASK);
return masked_function_code == ModbusFunctionCode::WRITE_SINGLE_COIL ||
masked_function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER ||
masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS ||
masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS;
}
inline bool is_function_code_exception(uint8_t function_code) {
return (static_cast<uint8_t>(function_code) & FUNCTION_CODE_EXCEPTION_MASK) != 0;
}
inline bool is_function_code_custom(uint8_t function_code) {
uint8_t masked_function_code = function_code & FUNCTION_CODE_MASK;
return (masked_function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_1_INIT &&
masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_1_END) ||
(masked_function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT &&
masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END);
}
// Returns the expected length of a server response frame based on the function code
// If the frame is too short to determine the length, returns the minimum length
uint16_t server_frame_length(const uint8_t *frame, size_t size);
// Returns the expected length of a client request frame based on the function code
// If the frame is too short to determine the length, returns the minimum length
uint16_t client_frame_length(const uint8_t *frame, size_t size);
inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) {
if (size < 2)
return 0;
switch (static_cast<ModbusFunctionCode>(frame[1])) {
case ModbusFunctionCode::READ_COILS:
case ModbusFunctionCode::READ_DISCRETE_INPUTS:
case ModbusFunctionCode::READ_HOLDING_REGISTERS:
case ModbusFunctionCode::READ_INPUT_REGISTERS:
return 3; // address(1) + function(1) + byte count(1) + data + CRC(2)
default:
return 2;
}
}
inline uint8_t client_frame_data_offset(const uint8_t *, size_t) { return 2; }
enum class SensorValueType : uint8_t { enum class SensorValueType : uint8_t {
RAW = 0x00, // variable length RAW = 0x00, // variable length
U_WORD = 0x1, // 1 Register unsigned U_WORD = 0x1, // 1 Register unsigned
@@ -41,21 +93,21 @@ inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_t
case ModbusRegisterType::READ: case ModbusRegisterType::READ:
return ModbusFunctionCode::READ_INPUT_REGISTERS; return ModbusFunctionCode::READ_INPUT_REGISTERS;
default: default:
return ModbusFunctionCode::CUSTOM; return ModbusFunctionCode::INVALID;
} }
} }
inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type) { inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type, bool multiple = false) {
switch (reg_type) { switch (reg_type) {
case ModbusRegisterType::COIL: case ModbusRegisterType::COIL:
return ModbusFunctionCode::WRITE_SINGLE_COIL; return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_COILS : ModbusFunctionCode::WRITE_SINGLE_COIL;
case ModbusRegisterType::DISCRETE_INPUT:
return ModbusFunctionCode::CUSTOM;
case ModbusRegisterType::HOLDING: case ModbusRegisterType::HOLDING:
return ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS; return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS : ModbusFunctionCode::WRITE_SINGLE_REGISTER;
// These register types can't be written (per spec)
case ModbusRegisterType::READ: case ModbusRegisterType::READ:
case ModbusRegisterType::DISCRETE_INPUT:
default: default:
return ModbusFunctionCode::CUSTOM; return ModbusFunctionCode::INVALID;
} }
} }
@@ -112,31 +164,31 @@ inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) {
* @param buffer_offset offset in bytes. * @param buffer_offset offset in bytes.
* @return value of type T extracted from buffer * @return value of type T extracted from buffer
*/ */
template<typename T> T get_data(const std::vector<uint8_t> &data, size_t buffer_offset) { template<typename T> T get_data(const uint8_t *data, size_t buffer_offset) {
if (sizeof(T) == sizeof(uint8_t)) { if (sizeof(T) == sizeof(uint8_t)) {
return T(data[buffer_offset]); return T(data[buffer_offset]);
} }
if (sizeof(T) == sizeof(uint16_t)) { if (sizeof(T) == sizeof(uint16_t)) {
return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0)); return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0));
} }
if (sizeof(T) == sizeof(uint32_t)) { if (sizeof(T) == sizeof(uint32_t)) {
return static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset)) << 16 | return static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset)) << 16 |
static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset + 2)); static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset + 2));
} }
if (sizeof(T) == sizeof(uint64_t)) { if (sizeof(T) == sizeof(uint64_t)) {
return static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset)) << 32 | return static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset)) << 32 |
(static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset + 4))); (static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset + 4)));
} }
static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) || static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) ||
sizeof(T) == sizeof(uint64_t), sizeof(T) == sizeof(uint64_t),
"Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported."); "Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported.");
return T{}; return T{};
} }
template<typename T> T get_data(const std::vector<uint8_t> &data, size_t buffer_offset) {
return get_data<T>(data.data(), buffer_offset);
}
/** Extract coil data from modbus response buffer /** Extract coil data from modbus response buffer
* Responses for coil are packed into bytes . * Responses for coil are packed into bytes .
* coil 3 is bit 3 of the first response byte * coil 3 is bit 3 of the first response byte
@@ -188,7 +240,27 @@ void number_to_payload(std::vector<uint16_t> &data, int64_t value, SensorValueTy
* @return 64-bit number of the payload * @return 64-bit number of the payload
*/ */
int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sensor_value_type, uint8_t offset, int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sensor_value_type, uint8_t offset,
uint32_t bitmask); uint32_t bitmask, bool *error_return = nullptr);
/** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs.
* @param function_code the modbus function code to use. One of:
* READ_COILS
* READ_DISCRETE_INPUTS
* READ_HOLDING_REGISTERS
* READ_INPUT_REGISTERS
* WRITE_SINGLE_COIL
* WRITE_SINGLE_REGISTER
* WRITE_MULTIPLE_COILS
* WRITE_MULTIPLE_REGISTERS
* @param start_address coil/register/input starting address
* @param number_of_entities number of coils/registers/inputs to read/write
* @param values optional payload bytes to write (nullptr for read commands)
* @param values_len length of values array
* @return PDU (function code + data, no address, no CRC)
*/
StaticVector<uint8_t, MAX_PDU_SIZE> create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address,
uint16_t number_of_entities, const uint8_t *values = nullptr,
size_t values_len = 0);
inline std::vector<uint16_t> float_to_payload(float value, SensorValueType value_type) { inline std::vector<uint16_t> float_to_payload(float value, SensorValueType value_type) {
int64_t val; int64_t val;

View File

@@ -201,7 +201,7 @@ void ModbusController::update() {
// walk through the sensors and determine the register ranges to read // walk through the sensors and determine the register ranges to read
size_t ModbusController::create_register_ranges_() { size_t ModbusController::create_register_ranges_() {
this->register_ranges_.clear(); this->register_ranges_.clear();
if (this->parent_->role == modbus::ModbusRole::CLIENT && this->sensorset_.empty()) { if (this->sensorset_.empty()) {
ESP_LOGW(TAG, "No sensors registered"); ESP_LOGW(TAG, "No sensors registered");
return 0; return 0;
} }

View File

@@ -279,7 +279,7 @@ class ModbusCommandItem {
* Responses for the commands are dispatched to the modbus sensor items. * Responses for the commands are dispatched to the modbus sensor items.
*/ */
class ModbusController : public PollingComponent, public modbus::ModbusDevice { class ModbusController : public PollingComponent, public modbus::ModbusClientDevice {
public: public:
void dump_config() override; void dump_config() override;
void loop() override; void loop() override;

View File

@@ -27,7 +27,7 @@ MULTI_CONF = True
modbus_server_ns = cg.esphome_ns.namespace("modbus_server") modbus_server_ns = cg.esphome_ns.namespace("modbus_server")
ModbusServer = modbus_server_ns.class_( ModbusServer = modbus_server_ns.class_(
"ModbusServer", cg.Component, modbus.ModbusDevice "ModbusServer", cg.Component, modbus.ModbusServerDevice
) )
ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse") ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse")
@@ -44,7 +44,7 @@ SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema(
ModbusServerRegisterSchema = cv.Schema( ModbusServerRegisterSchema = cv.Schema(
{ {
cv.GenerateID(): cv.declare_id(ServerRegister), cv.GenerateID(): cv.declare_id(ServerRegister),
cv.Required(CONF_ADDRESS): cv.positive_int, cv.Required(CONF_ADDRESS): cv.hex_uint16_t,
cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE),
cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, cv.Required(CONF_READ_LAMBDA): cv.returning_lambda,
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
@@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.All(
CONF_REGISTERS, CONF_REGISTERS,
): cv.ensure_list(ModbusServerRegisterSchema), ): cv.ensure_list(ModbusServerRegisterSchema),
} }
).extend(modbus.modbus_device_schema(0x01)), ).extend(modbus.modbus_device_schema(0x01, role="server")),
) )
@@ -119,6 +119,5 @@ async def to_code(config):
) )
) )
cg.add(var.add_server_register(server_register_var)) cg.add(var.add_server_register(server_register_var))
cg.add(var.set_address(config[CONF_ADDRESS]))
await cg.register_component(var, config) await cg.register_component(var, config)
return await modbus.register_modbus_device(var, config) return await modbus.register_modbus_server_device(var, config)

View File

@@ -5,6 +5,7 @@
namespace esphome::modbus_server { namespace esphome::modbus_server {
using modbus::ModbusFunctionCode; using modbus::ModbusFunctionCode;
using modbus::ModbusExceptionCode; using modbus::ModbusExceptionCode;
using modbus::helpers::payload_to_number;
static const char *const TAG = "modbus_server"; static const char *const TAG = "modbus_server";
@@ -16,7 +17,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star
this->address_, function_code, start_address, number_of_registers); this->address_, function_code, start_address, number_of_registers);
if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) { if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) {
ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers);
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
return; return;
} }
@@ -30,9 +31,10 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star
break; break;
} }
int64_t value = server_register->read_lambda(); int64_t value = server_register->read_lambda();
char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE];
ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.",
server_register->address, static_cast<size_t>(server_register->value_type), server_register->address, static_cast<size_t>(server_register->value_type),
server_register->register_count, server_register->format_value(value).c_str()); server_register->register_count, server_register->format_value(value, value_buf, sizeof(value_buf)));
std::vector<uint16_t> payload; std::vector<uint16_t> payload;
payload.reserve(server_register->register_count * 2); payload.reserve(server_register->register_count * 2);
@@ -49,7 +51,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star
(current_address <= this->server_courtesy_response_.register_last_address)) { (current_address <= this->server_courtesy_response_.register_last_address)) {
ESP_LOGV(TAG, ESP_LOGV(TAG,
"Could not match any register to address 0x%02X, but default allowed. " "Could not match any register to address 0x%02X, but default allowed. "
"Returning default value: %d.", "Returning default value: %" PRIu16 ".",
current_address, this->server_courtesy_response_.register_value); current_address, this->server_courtesy_response_.register_value);
sixteen_bit_response.push_back(this->server_courtesy_response_.register_value); sixteen_bit_response.push_back(this->server_courtesy_response_.register_value);
current_address += 1; // Just increment by 1, as the default response is a single register current_address += 1; // Just increment by 1, as the default response is a single register
@@ -64,20 +66,22 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star
} }
std::vector<uint8_t> response; std::vector<uint8_t> response;
if (number_of_registers != sixteen_bit_response.size())
ESP_LOGW(TAG, "Response size not matched to request register count.");
response.push_back(sixteen_bit_response.size() * 2); // actual byte count
for (auto v : sixteen_bit_response) { for (auto v : sixteen_bit_response) {
auto decoded_value = decode_value(v); auto decoded_value = decode_value(v);
response.push_back(decoded_value[0]); response.push_back(decoded_value[0]);
response.push_back(decoded_value[1]); response.push_back(decoded_value[1]);
} }
this->send(function_code, response);
this->send(function_code, start_address, number_of_registers, response.size(), response.data());
} }
void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::vector<uint8_t> &data) { void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::vector<uint8_t> &data) {
uint16_t number_of_registers; uint16_t number_of_registers;
uint16_t payload_offset; uint16_t payload_offset;
if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { if (static_cast<ModbusFunctionCode>(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
if (data.size() < 5) { if (data.size() < 5) {
ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size()); ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size());
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
@@ -85,13 +89,15 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v
} }
number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8); number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8);
if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) { if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) {
ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers);
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
return; return;
} }
uint16_t payload_size = data[4]; uint16_t payload_size = data[4];
if (payload_size != number_of_registers * 2) { if (payload_size != number_of_registers * 2) {
ESP_LOGW(TAG, "Payload size of %d bytes is not 2 times the number of registers (%d). Sending exception response.", ESP_LOGW(TAG,
"Payload size of %" PRIu16 " bytes is not 2 times the number of registers (%" PRIu16
"). Sending exception response.",
payload_size, number_of_registers); payload_size, number_of_registers);
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
return; return;
@@ -103,7 +109,7 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v
return; return;
} }
payload_offset = 5; payload_offset = 5;
} else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { } else if (static_cast<ModbusFunctionCode>(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER) {
if (data.size() < 4) { if (data.size() < 4) {
ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size()); ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size());
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
@@ -148,15 +154,22 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v
if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool { if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool {
return server_register->write_lambda != nullptr; return server_register->write_lambda != nullptr;
})) { })) {
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); ESP_LOGW(TAG, "Invalid register address. Sending exception response.");
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
return; return;
} }
// Actually write to the registers: // Actually write to the registers:
if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) {
int64_t number = modbus::helpers::payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); bool error = false;
int64_t number = payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF, &error);
if (error) {
return false;
} else {
return server_register->write_lambda(number); return server_register->write_lambda(number);
}
})) { })) {
ESP_LOGW(TAG, "Could not write all registers. Sending exception response.");
this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE);
return; return;
} }

View File

@@ -52,32 +52,34 @@ class ServerRegister {
}; };
} }
// Formats a raw value into a string representation based on the value type for debugging
std::string format_value(int64_t value) const {
// max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit)
// plus null terminator = 43, rounded to 44 for 4-byte alignment // plus null terminator = 43, rounded to 44 for 4-byte alignment
char buf[44]; static constexpr size_t FORMAT_VALUE_BUF_SIZE = 44;
// Formats a raw value into a caller-provided buffer based on the value type for debugging.
// Returns buf for convenience.
const char *format_value(int64_t value, char *buf, size_t buf_size) const {
switch (this->value_type) { switch (this->value_type) {
case SensorValueType::U_WORD: case SensorValueType::U_WORD:
case SensorValueType::U_DWORD: case SensorValueType::U_DWORD:
case SensorValueType::U_DWORD_R: case SensorValueType::U_DWORD_R:
case SensorValueType::U_QWORD: case SensorValueType::U_QWORD:
case SensorValueType::U_QWORD_R: case SensorValueType::U_QWORD_R:
buf_append_printf(buf, sizeof(buf), 0, "%" PRIu64, static_cast<uint64_t>(value)); buf_append_printf(buf, buf_size, 0, "%" PRIu64, static_cast<uint64_t>(value));
return buf; return buf;
case SensorValueType::S_WORD: case SensorValueType::S_WORD:
case SensorValueType::S_DWORD: case SensorValueType::S_DWORD:
case SensorValueType::S_DWORD_R: case SensorValueType::S_DWORD_R:
case SensorValueType::S_QWORD: case SensorValueType::S_QWORD:
case SensorValueType::S_QWORD_R: case SensorValueType::S_QWORD_R:
buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); buf_append_printf(buf, buf_size, 0, "%" PRId64, value);
return buf; return buf;
case SensorValueType::FP32_R: case SensorValueType::FP32_R:
case SensorValueType::FP32: case SensorValueType::FP32:
buf_append_printf(buf, sizeof(buf), 0, "%.1f", bit_cast<float>(static_cast<uint32_t>(value))); buf_append_printf(buf, buf_size, 0, "%.1f", bit_cast<float>(static_cast<uint32_t>(value)));
return buf; return buf;
default: default:
buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); buf_append_printf(buf, buf_size, 0, "%" PRId64, value);
return buf; return buf;
} }
} }
@@ -89,12 +91,10 @@ class ServerRegister {
WriteLambda write_lambda; WriteLambda write_lambda;
}; };
class ModbusServer : public Component, public modbus::ModbusDevice { class ModbusServer : public Component, public modbus::ModbusServerDevice {
public: public:
void dump_config() override; void dump_config() override;
/// Not used for ModbusServer.
void on_modbus_data(const std::vector<uint8_t> &data) override{};
/// Registers a server register with the controller. Called by esphomes code generator /// Registers a server register with the controller. Called by esphomes code generator
void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); }
/// called when a modbus request (function code 0x03 or 0x04) was parsed without errors /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors

View File

@@ -4,6 +4,181 @@
namespace esphome::modbus::helpers { namespace esphome::modbus::helpers {
using FC = ModbusFunctionCode;
// --- server_frame_length ---------------------------------------------------
// Frame layout: address(1) + function(1) + ... + CRC(2). Fixtures borrowed from
// tests/integration/fixtures/uart_mock_modbus.yaml.
TEST(ModbusServerFrameLength, TooShortReturnsMinimum) {
const uint8_t frame[] = {0x01};
EXPECT_EQ(server_frame_length(frame, 1), MIN_FRAME_SIZE);
}
TEST(ModbusServerFrameLength, ReadHoldingUsesByteCount) {
// inject_rx for basic_register: 2 data bytes -> 5 + 2 = 7
const uint8_t frame[] = {0x01, 0x03, 0x02, 0x01, 0x03, 0xF9, 0xD5};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 7);
}
TEST(ModbusServerFrameLength, ReadByteCountCappedAtMax) {
const uint8_t frame[] = {0x01, 0x03, 0xFF}; // claim 255 bytes
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5 + MAX_NUM_OF_REGISTERS_TO_READ * 2);
}
TEST(ModbusServerFrameLength, ReadMissingByteCountReturnsHeaderOnly) {
const uint8_t frame[] = {0x01, 0x03};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5);
}
TEST(ModbusServerFrameLength, ExceptionResponse) {
// exception_response fixture: function code 0x83 has the exception bit set
const uint8_t frame[] = {0x01, 0x83, 0x02, 0xC0, 0xF1};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5);
}
TEST(ModbusServerFrameLength, WriteResponsesAreFixed) {
for (FC fc :
{FC::WRITE_SINGLE_COIL, FC::WRITE_SINGLE_REGISTER, FC::WRITE_MULTIPLE_COILS, FC::WRITE_MULTIPLE_REGISTERS}) {
const uint8_t frame[] = {0x01, static_cast<uint8_t>(fc)};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 8) << "fc=" << static_cast<int>(fc);
}
}
TEST(ModbusServerFrameLength, MiscFixedAndUnknown) {
const uint8_t mask[] = {0x01, static_cast<uint8_t>(FC::MASK_WRITE_REGISTER)};
const uint8_t fifo[] = {0x01, static_cast<uint8_t>(FC::READ_FIFO_QUEUE)};
const uint8_t unknown[] = {0x01, 0x42};
EXPECT_EQ(server_frame_length(mask, sizeof(mask)), 10);
EXPECT_EQ(server_frame_length(fifo, sizeof(fifo)), 6);
EXPECT_EQ(server_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE);
}
// --- client_frame_length ---------------------------------------------------
TEST(ModbusClientFrameLength, TooShortReturnsMinimum) {
const uint8_t frame[] = {0x01};
EXPECT_EQ(client_frame_length(frame, 1), MIN_FRAME_SIZE);
}
TEST(ModbusClientFrameLength, ReadAndWriteSingleAreFixed) {
// basic_register request fixture is a read-holding request -> 8 bytes
const uint8_t read[] = {0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A};
EXPECT_EQ(client_frame_length(read, sizeof(read)), 8);
for (FC fc : {FC::READ_COILS, FC::READ_DISCRETE_INPUTS, FC::READ_INPUT_REGISTERS, FC::WRITE_SINGLE_COIL,
FC::WRITE_SINGLE_REGISTER}) {
const uint8_t frame[] = {0x01, static_cast<uint8_t>(fc)};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 8) << "fc=" << static_cast<int>(fc);
}
}
TEST(ModbusClientFrameLength, WriteMultipleUsesByteCount) {
// write 2 registers (4 data bytes): addr(2)+qty(2)+count(1) then data; count is frame[6]
const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + 4);
}
TEST(ModbusClientFrameLength, WriteMultipleByteCountCapped) {
const uint8_t frame[] = {0x01, 0x0F, 0x00, 0x00, 0x00, 0x02, 0xFF};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + MAX_NUM_OF_REGISTERS_TO_WRITE * 2);
}
TEST(ModbusClientFrameLength, WriteMultipleMissingByteCount) {
const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9);
}
TEST(ModbusClientFrameLength, MiscFixedAndUnknown) {
const uint8_t mask[] = {0x01, static_cast<uint8_t>(FC::MASK_WRITE_REGISTER)};
const uint8_t fifo[] = {0x01, static_cast<uint8_t>(FC::READ_FIFO_QUEUE)};
const uint8_t unknown[] = {0x01, 0x42};
EXPECT_EQ(client_frame_length(mask, sizeof(mask)), 10);
EXPECT_EQ(client_frame_length(fifo, sizeof(fifo)), 6);
EXPECT_EQ(client_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE);
}
// --- create_client_pdu -----------------------------------------------------
// PDU = function code + data (no address, no CRC).
TEST(ModbusCreateClientPdu, ReadHolding) {
auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0003, 1);
const std::vector<uint8_t> expected{0x03, 0x00, 0x03, 0x00, 0x01};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
}
TEST(ModbusCreateClientPdu, WriteSingleOmitsQuantity) {
const uint8_t values[] = {0x00, 0x0B};
auto pdu = create_client_pdu(FC::WRITE_SINGLE_REGISTER, 0x0003, 1, values, sizeof(values));
const std::vector<uint8_t> expected{0x06, 0x00, 0x03, 0x00, 0x0B};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
}
TEST(ModbusCreateClientPdu, WriteSingleTooFewValuesReturnsEmpty) {
const uint8_t values[] = {0x00};
auto pdu = create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, values, sizeof(values));
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, WriteMultipleIncludesByteCount) {
const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16};
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, sizeof(values));
const std::vector<uint8_t> expected{0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
}
TEST(ModbusCreateClientPdu, WriteMultipleOverCapacityReturnsEmpty) {
std::vector<uint8_t> values(MAX_PDU_SIZE - 6 + 1, 0xAA);
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 1, values.data(), values.size());
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, UnsupportedFunctionCodeReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_FIFO_QUEUE, 0x0000, 1);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, ZeroEntitiesReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0000, 0);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, WriteWithoutValuesReturnsEmpty) {
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 1, nullptr, 0);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, ReadHoldingOverMaxReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0000, MAX_NUM_OF_REGISTERS_TO_READ + 1);
EXPECT_TRUE(pdu.empty());
}
// Regression: coils allow up to 2000 entities, well above the 125 register limit.
// A switch fall-through previously subjected coil/discrete reads to the register limit.
TEST(ModbusCreateClientPdu, ReadCoilsAboveRegisterLimitIsValid) {
const uint16_t quantity = MAX_NUM_OF_REGISTERS_TO_READ + 1; // 126: valid for coils, too many for registers
auto pdu = create_client_pdu(FC::READ_COILS, 0x0000, quantity);
const std::vector<uint8_t> expected{0x01, 0x00, 0x00, static_cast<uint8_t>(quantity >> 8),
static_cast<uint8_t>(quantity & 0xFF)};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
}
TEST(ModbusCreateClientPdu, ReadCoilsOverMaxReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_COILS, 0x0000, MAX_NUM_OF_COILS_TO_READ + 1);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, ReadDiscreteInputsOverMaxReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_DISCRETE_INPUTS, 0x0000, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ + 1);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) {
const uint8_t values[] = {0x00, 0x0B};
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, MAX_NUM_OF_REGISTERS_TO_WRITE + 1, values,
sizeof(values));
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) {
const std::vector<uint8_t> data{0x12, 0x34}; const std::vector<uint8_t> data{0x12, 0x34};
EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0); EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0);

View File

@@ -1,59 +0,0 @@
#include <gtest/gtest.h>
#include "esphome/components/modbus/modbus.h"
#include "esphome/core/helpers.h"
namespace esphome::modbus {
// Exposes protected methods for testing.
class TestModbus : public Modbus {
public:
bool test_parse_modbus_byte(uint8_t byte) { return this->parse_modbus_byte_(byte); }
void test_clear_rx_buffer() { this->rx_buffer_.clear(); }
void set_waiting(uint8_t addr) { this->waiting_for_response_ = addr; }
};
class MockDevice : public ModbusDevice {
public:
void on_modbus_data(const std::vector<uint8_t> &data) override { this->data_received = true; }
bool data_received{false};
};
TEST(ModbusTest, TwoByteRegressionTest) {
TestModbus modbus;
modbus.set_role(ModbusRole::CLIENT);
// First byte (at=0)
EXPECT_TRUE(modbus.test_parse_modbus_byte(0x01));
// Second byte (at=1)
// This used to reach raw[2] because it skipped the if(at==2) check, causing a
// buffer overflow.
EXPECT_TRUE(modbus.test_parse_modbus_byte(0x03));
}
TEST(ModbusTest, TestValidFrame) {
TestModbus modbus;
modbus.set_role(ModbusRole::CLIENT);
MockDevice device;
device.set_parent(&modbus);
device.set_address(0x01);
modbus.register_device(&device);
modbus.set_waiting(0x01);
// Address 1, Function 3, Length 2, Data 0x1234
uint8_t frame_data[] = {0x01, 0x03, 0x02, 0x12, 0x34};
uint16_t crc = esphome::crc16(frame_data, sizeof(frame_data));
std::vector<uint8_t> frame;
for (uint8_t b : frame_data)
frame.push_back(b);
frame.push_back(crc & 0xFF);
frame.push_back((crc >> 8) & 0xFF);
for (size_t i = 0; i < frame.size(); i++) {
bool result = modbus.test_parse_modbus_byte(frame[i]);
EXPECT_TRUE(result) << "Failed at byte " << i << " (0x" << std::hex << (int) frame[i] << ")";
}
EXPECT_TRUE(device.data_received);
}
} // namespace esphome::modbus

View File

@@ -49,15 +49,16 @@ modbus_controller:
- address: 1 - address: 1
id: modbus_controller_ok id: modbus_controller_ok
max_cmd_retries: 2 max_cmd_retries: 2
update_interval: 1s # Update interval is set to never to prevent automatic polling: the test will trigger requests by pressing the "Start Scenario" button
update_interval: never
- address: 2 - address: 2
id: modbus_controller_slow id: modbus_controller_slow
max_cmd_retries: 0 max_cmd_retries: 0
update_interval: 1s update_interval: never
- address: 3 - address: 3
id: modbus_controller_offline id: modbus_controller_offline
max_cmd_retries: 0 max_cmd_retries: 0
update_interval: 1s update_interval: never
sensor: sensor:
- platform: modbus_controller - platform: modbus_controller
@@ -91,4 +92,11 @@ button:
name: "Start Scenario" name: "Start Scenario"
id: start_scenario_btn id: start_scenario_btn
on_press: on_press:
- lambda: "id(virtual_uart_dev).start_scenario();" - lambda: |-
id(virtual_uart_dev).start_scenario();
id(modbus_controller_ok).set_update_interval(1000);
id(modbus_controller_ok).start_poller();
id(modbus_controller_slow).set_update_interval(1000);
id(modbus_controller_slow).start_poller();
id(modbus_controller_offline).set_update_interval(1000);
id(modbus_controller_offline).start_poller();

View File

@@ -54,7 +54,11 @@ modbus:
sensor: sensor:
- platform: sdm_meter - platform: sdm_meter
address: 2 address: 2
update_interval: 1s id: sdm_meter_1
# update_interval is set to never to avoid automatic polling before the test starts the scenario.
# The test will manually start the poller after subscribing to states, to ensure no state changes are missed.
# This also allows us to assert there are no modbus errors/warnings during the initial request/response.
update_interval: never
phase_a: phase_a:
voltage: voltage:
name: sdm_voltage name: sdm_voltage
@@ -64,4 +68,7 @@ button:
name: "Start Scenario" name: "Start Scenario"
id: start_scenario_btn id: start_scenario_btn
on_press: on_press:
- lambda: "id(virtual_uart_dev).start_scenario();" - lambda: |-
id(virtual_uart_dev).start_scenario();
id(sdm_meter_1).set_update_interval(1000);
id(sdm_meter_1).start_poller();

View File

@@ -53,8 +53,8 @@ modbus:
modbus_controller: modbus_controller:
- address: 1 - address: 1
modbus_id: virtual_modbus_controller modbus_id: virtual_modbus_controller
update_interval: 1s
id: modbus_controller_1 id: modbus_controller_1
update_interval: 1s
modbus_server: modbus_server:
- address: 1 - address: 1
@@ -176,6 +176,4 @@ button:
- platform: template - platform: template
name: "Start Scenario" name: "Start Scenario"
id: start_scenario_btn id: start_scenario_btn
on_press: # This test does not have anything to start (mock is autostart)
- lambda: "id(virtual_uart_server).start_scenario();"
- lambda: "id(virtual_uart_controller).start_scenario();"

View File

@@ -113,7 +113,4 @@ button:
- platform: template - platform: template
name: "Start Scenario" name: "Start Scenario"
id: start_scenario_btn id: start_scenario_btn
on_press: # This test does not have anything to start (mock is autostart)
- lambda: "id(virtual_uart_server).start_scenario();"
- lambda: "id(virtual_uart_server_2).start_scenario();"
- lambda: "id(virtual_uart_controller).start_scenario();"

View File

@@ -326,6 +326,4 @@ button:
- platform: template - platform: template
name: "Start Scenario" name: "Start Scenario"
id: start_scenario_btn id: start_scenario_btn
on_press: # This test does not have anything to start (mock is autostart)
- lambda: "id(virtual_uart_server).start_scenario();"
- lambda: "id(virtual_uart_controller).start_scenario();"

View File

@@ -53,7 +53,11 @@ modbus:
sensor: sensor:
- platform: sdm_meter - platform: sdm_meter
address: 2 address: 2
update_interval: 1s id: sdm_meter_1
# update_interval is set to never to avoid automatic polling before the test starts the scenario.
# The test will manually start the poller after subscribing to states, to ensure no state changes are missed.
# This also allows us to assert there are no modbus errors/warnings during the initial request/response.
update_interval: never
phase_a: phase_a:
voltage: voltage:
name: sdm_voltage name: sdm_voltage
@@ -63,4 +67,7 @@ button:
name: "Start Scenario" name: "Start Scenario"
id: start_scenario_btn id: start_scenario_btn
on_press: on_press:
- lambda: "id(virtual_uart_dev).start_scenario();" - lambda: |-
id(virtual_uart_dev).start_scenario();
id(sdm_meter_1).set_update_interval(1000);
id(sdm_meter_1).start_poller();

View File

@@ -127,15 +127,18 @@ async def test_uart_mock_modbus_timing(
) -> None: ) -> None:
"""Test modbus timing with multi-register SDM meter response.""" """Test modbus timing with multi-register SDM meter response."""
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
tracker = SensorTracker(["sdm_voltage"]) tracker = SensorTracker(["sdm_voltage"])
voltage_changed = tracker.expect_any("sdm_voltage") voltage_changed = tracker.expect_any("sdm_voltage")
async with ( async with (
run_compiled(yaml_config), run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client, api_client_connected() as client,
): ):
await tracker.setup_and_start_scenario(client) await tracker.setup_and_start_scenario(client)
await tracker.await_change(voltage_changed, "sdm_voltage") await tracker.await_change(voltage_changed, "sdm_voltage")
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -148,26 +151,25 @@ async def test_uart_mock_modbus_no_threshold(
Without the 50ms fallback timeout, the chunked response with a 40ms gap Without the 50ms fallback timeout, the chunked response with a 40ms gap
between USB packets would cause a false timeout and CRC failure cascade. between USB packets would cause a false timeout and CRC failure cascade.
Bus-level warnings (CRC failures, buffer clears) are expected during Bus-level warnings (CRC/parse failures, buffer clears) are NOT expected during
chunked reassembly — the test only verifies the final value arrives. chunked reassembly, if timeouts are set properly — these warnings indicate undersized timeouts.
""" """
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
tracker = SensorTracker(["sdm_voltage"]) tracker = SensorTracker(["sdm_voltage"])
voltage_changed = tracker.expect_any("sdm_voltage") voltage_changed = tracker.expect_any("sdm_voltage")
async with ( async with (
run_compiled(yaml_config), run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client, api_client_connected() as client,
): ):
await tracker.setup_and_start_scenario(client) await tracker.setup_and_start_scenario(client)
await tracker.await_change(voltage_changed, "sdm_voltage") await tracker.await_change(voltage_changed, "sdm_voltage")
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.xfail(
reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.",
strict=True,
)
async def test_uart_mock_modbus_server( async def test_uart_mock_modbus_server(
yaml_config: str, yaml_config: str,
run_compiled: RunCompiledFunction, run_compiled: RunCompiledFunction,
@@ -308,10 +310,6 @@ async def test_uart_mock_modbus_server_controller_write(
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.xfail(
reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.",
strict=True,
)
async def test_uart_mock_modbus_server_controller_multiple( async def test_uart_mock_modbus_server_controller_multiple(
yaml_config: str, yaml_config: str,
run_compiled: RunCompiledFunction, run_compiled: RunCompiledFunction,