[modbus] Frame accessors, PDU-relative lengths, and span-based send_pdu (#17846)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bonne Eggleston
2026-07-25 03:09:05 +00:00
committed by GitHub
co-authored by Claude Fable 5
parent 58385bebff
commit eaa79b3696
10 changed files with 366 additions and 81 deletions
+32 -23
View File
@@ -51,7 +51,7 @@ void ModbusClientHub::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.data()[0];
uint8_t expected_address = wfr.frame.address();
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,
@@ -258,13 +258,13 @@ bool ModbusServerHub::parse_modbus_client_frame_() {
}
// Bounds contract, enforced by the parser (parse_modbus_server_frame_) rather than locally:
// - pdu is never empty: helpers::server_frame_length() returns at least MIN_FRAME_SIZE (4) on every
// branch, and find_custom_frame_end_() only ever lengthens that, so the PDU (frame minus address
// and CRC) always holds at least the function code.
// - When the exception bit is set, pdu has at least 2 bytes: server_frame_length() checks the
// exception bit before anything else and pins those frames to 5 bytes, so the exception code
// - pdu is never empty: helpers::server_pdu_length() returns at least MIN_PDU_SIZE (1) on every
// branch, and find_custom_frame_end_() only ever lengthens the frame, so the PDU always holds
// at least the function code.
// - When the exception bit is set, pdu has at least 2 bytes: server_pdu_length() checks the
// exception bit before anything else and pins those PDUs to 2 bytes, so the exception code
// read below is always present.
// Keep those guarantees in mind when changing server_frame_length() or adding callers.
// Keep those guarantees in mind when changing server_pdu_length() or adding callers.
void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) {
const uint8_t function_code = pdu[0];
if (!this->waiting_for_response_.has_value()) {
@@ -276,8 +276,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span<con
// 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.data()[0];
uint8_t expected_function_code = wfr.frame.data.data()[1];
uint8_t expected_address = wfr.frame.address();
uint8_t expected_function_code = wfr.frame.pdu()[0];
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
@@ -304,7 +304,7 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, std::span<con
this->waiting_for_response_.reset();
ModbusClientDevice *device = command.device;
// The request PDU is the sent frame without the leading address and the trailing CRC.
std::span<const uint8_t> request_pdu(command.frame.data.data() + 1, command.frame.size() - 3);
std::span<const uint8_t> request_pdu = command.frame.pdu();
// Is it an error response?
if (helpers::is_function_code_exception(function_code)) {
uint8_t exception = pdu[1]; // exception frames are fixed-length, so the code is always present
@@ -586,18 +586,26 @@ void ModbusClientHub::notify_no_response_(ModbusDeviceCommand &wfr) {
void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) {
const ModbusFrame &frame = wfr.frame;
if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) {
ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.data.data()[0]);
ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.address());
if (wfr.device != nullptr)
wfr.device->on_not_sent();
return;
}
// Re-queue a copy (not a move): the waiting entry may have to survive as an interrupted shell.
this->tx_buffer_.emplace_back(wfr.device, frame.data.data()[0], frame.data.data() + 1, frame.size() - 3);
this->tx_buffer_.emplace_back(wfr.device, frame.address(), frame.pdu());
}
// Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload.
void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) {
if (pdu_len == 0) {
void ModbusClientHub::send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device) {
if (pdu.empty()) {
if (device)
device->on_not_sent();
return;
}
// Bound the PDU so the wire frame (address + pdu + CRC) stays within the Modbus RTU 256-byte limit.
if (pdu.size() > MAX_PDU_SIZE) {
ESP_LOGE(TAG, "Frame too large, dropped: %" PRIu8 ":%zu bytes", address, pdu.size());
if (device)
device->on_not_sent();
return;
@@ -607,13 +615,15 @@ void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t p
#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);
ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address,
format_hex_pretty_to(hex_buf, pdu.data(), pdu.size()));
this->tx_buffer_.emplace_back(device, address, pdu);
} else {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len));
ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address,
format_hex_pretty_to(hex_buf, pdu.data(), pdu.size()));
if (device)
device->on_not_sent();
}
@@ -622,13 +632,12 @@ void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t p
void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) {
// 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(),
[address](const ModbusDeviceCommand &cmd) { return cmd.frame.data.data()[0] == address; }),
tx_buffer.end());
tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(),
[address](const ModbusDeviceCommand &cmd) { return cmd.frame.address() == 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.data()[0] == address) {
if (this->waiting_for_response_.value().frame.address() == 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;
@@ -657,7 +666,7 @@ void ModbusClientHub::send_raw(const std::vector<uint8_t> &payload, ModbusClient
device->on_not_sent();
return;
}
this->queue_raw_(payload[0], payload.data() + 1, static_cast<uint16_t>(payload.size() - 1), device);
this->send_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), device);
}
// Send raw command for server replies immediately. Except CRC everything must be contained in payload
+24 -8
View File
@@ -40,6 +40,13 @@ struct ModbusFrame {
}
uint16_t size() const { return static_cast<uint16_t>(this->data.size()); }
// A frame is [address][PDU...][CRC lo][CRC hi]. These are the only places that need to know that layout
uint8_t address() const { return this->data.data()[0]; }
/// The PDU: function code + data, without address or CRC. Only valid while the frame is alive.
/// Requires a complete frame (size() >= MIN_FRAME_SIZE, guaranteed by the constructors) - the
/// subtraction would wrap on anything shorter.
std::span<const uint8_t> pdu() const { return std::span<const uint8_t>(this->data.data() + 1, this->size() - 3u); }
};
class Modbus : public uart::UARTDevice, public Component {
@@ -90,6 +97,10 @@ struct ModbusDeviceCommand {
ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, const uint8_t *src, uint16_t len)
: device(device), frame(address, src, len) {}
/// Build a command from a PDU span: a caller-supplied PDU, or an existing frame's own pdu() when re-queueing
/// Callers must bound the PDU to MAX_PDU_SIZE
ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, std::span<const uint8_t> pdu)
: device(device), frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())) {}
};
class ModbusClientHub : public Modbus {
@@ -109,9 +120,8 @@ class ModbusClientHub : public Modbus {
payload_len),
device);
};
void send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr) {
this->queue_raw_(address, pdu.data(), pdu.size(), device);
}
void send_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr);
ESPDEPRECATED("Use send_pdu(payload[0], <pdu bytes>, device) instead. Removed in 2027.2.0", "2026.8.0")
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);
@@ -125,7 +135,6 @@ class ModbusClientHub : public Modbus {
// wfr is the caller's checked reference to waiting_for_response_.
void notify_no_response_(ModbusDeviceCommand &wfr);
void requeue_waiting_frame_(ModbusDeviceCommand &wfr);
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};
@@ -165,6 +174,9 @@ class ModbusServerHub : public Modbus {
uint16_t deferred_payload_len_{0};
};
// Transaction status: std::nullopt on success, otherwise a Modbus exception code
using ResponseStatus = std::optional<ExceptionCode>;
class ModbusClientDevice {
public:
ModbusClientDevice() = default;
@@ -217,7 +229,14 @@ class ModbusClientDevice {
this);
}
void send_pdu(std::span<const uint8_t> pdu) { this->parent_->send_pdu(this->address_, pdu, this); }
void send_raw(const std::vector<uint8_t> &payload) { this->parent_->send_raw(payload, this); }
ESPDEPRECATED("Use send_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0")
void send_raw(const std::vector<uint8_t> &payload) {
if (payload.empty()) {
this->on_not_sent(); // match the hub-level send_raw(): a refused send is always signalled
return;
}
this->parent_->send_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this);
}
inline void clear_tx_queue_for_address(bool clear_sent = true) {
this->parent_->clear_tx_queue_for_address(this->address_, clear_sent);
}
@@ -238,9 +257,6 @@ class ModbusClientDevice {
using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 2026.12.0",
"2026.6.0") = ModbusClientDevice;
// Transaction status: std::nullopt on success, otherwise the Modbus exception code. Server handlers return it;
// (future) client response callbacks receive it. Named without a side prefix so both directions share it.
using ResponseStatus = std::optional<ExceptionCode>;
// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol
// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by
// the capacity of this type.
@@ -84,9 +84,15 @@ enum class ExceptionCode : uint8_t {
using ModbusExceptionCode ESPDEPRECATED("Use modbus::ExceptionCode instead. Removed in 2027.2.0",
"2026.8.0") = ExceptionCode;
// 6.11 15 (0x0F) Write Multiple Coils
static constexpr uint16_t MAX_NUM_OF_COILS_TO_WRITE = 1968; // 0x7B0
// 6.12 16 (0x10) Write Multiple registers:
static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B
// 6.17 23 (0x17) Read/Write Multiple Registers:
static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE_RW = 121; // 0x79
// 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
@@ -98,6 +104,7 @@ static constexpr uint16_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 MIN_PDU_SIZE = 1;
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 256 - CRC(2) = 254
static constexpr uint16_t MAX_FRAME_SIZE = 256;
+117 -34
View File
@@ -7,74 +7,157 @@ namespace esphome::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)
uint16_t server_pdu_length(const uint8_t *frame, size_t size) {
if (size < MIN_PDU_SIZE)
return MIN_PDU_SIZE;
if (is_function_code_exception(frame[0])) {
return 2; // function(1) + exception(1)
}
switch (static_cast<FunctionCode>(frame[1])) {
switch (static_cast<FunctionCode>(frame[0])) {
case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS:
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::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);
// function(1) + byte count(1) + data
return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0);
case FunctionCode::WRITE_SINGLE_COIL:
case FunctionCode::WRITE_SINGLE_REGISTER:
case FunctionCode::WRITE_MULTIPLE_COILS:
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2)
return 5; // function(1) + output/register address(2) + value(2)
// Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions.
case FunctionCode::READ_FILE_RECORD:
case FunctionCode::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);
// function(1) + byte count(1) + data
return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_PDU_SIZE - 2)) : 0);
case FunctionCode::MASK_WRITE_REGISTER:
return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2)
return 7; // function(1) + reference address(2) + AND mask(2) + OR mask(2)
case FunctionCode::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);
// function(1) + byte count(1) + data
return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0);
case FunctionCode::READ_FIFO_QUEUE:
// address(1) + function(1) + fifo address(2) CRC(2)
return 6;
// function(1) + fifo address(2)
return 3;
default:
return MIN_FRAME_SIZE; // unknown length
return MIN_PDU_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<FunctionCode>(frame[1])) {
uint16_t client_pdu_length(const uint8_t *frame, size_t size) {
if (size < MIN_PDU_SIZE)
return MIN_PDU_SIZE;
switch (static_cast<FunctionCode>(frame[0])) {
case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS:
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS:
// address(1) + function(1) + start address(2) + quantity(2) + CRC(2)
// function(1) + start address(2) + quantity(2)
case FunctionCode::WRITE_SINGLE_COIL:
case FunctionCode::WRITE_SINGLE_REGISTER:
return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2)
return 5; // function(1) + output/register address(2) + value(2)
case FunctionCode::WRITE_MULTIPLE_COILS:
case FunctionCode::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);
// function(1) + start address(2) + quantity(2) + byte count(1) + data
return 6 + (size > 5 ? std::min(frame[5], 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 FunctionCode::READ_FILE_RECORD:
case FunctionCode::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);
// function(1) + byte count(1) + data
return 2 + (size > 1 ? std::min(frame[1], uint8_t(MAX_PDU_SIZE - 2)) : 0);
case FunctionCode::MASK_WRITE_REGISTER:
return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2)
return 7; // function(1) + reference address(2) + AND mask(2) + OR mask(2)
case FunctionCode::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);
// function(1) + read start address(2) + read quantity(2) + write start address(2) +
// write quantity(2) + byte count(1) + data
return 10 + (size > 9 ? std::min(frame[9], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE_RW * 2)) : 0);
case FunctionCode::READ_FIFO_QUEUE:
// address(1) + function(1) + fifo address(2) CRC(2)
return 6;
// function(1) + fifo address(2)
return 3;
default:
return MIN_FRAME_SIZE; // unknown length
return MIN_PDU_SIZE; // unknown length
}
}
bool is_server_pdu_standard(const uint8_t *pdu, size_t size) {
if (server_pdu_length(pdu, size) != size)
return false;
switch (static_cast<FunctionCode>(pdu[0])) {
case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS:
// A conformant bit-read response carries at least one packed byte (up to 2000 bits = 250 bytes).
return pdu[1] != 0 && pdu[1] <= uint8_t((MAX_NUM_OF_COILS_TO_READ + 7) / 8);
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS:
// Registers are 2 bytes each: the byte count must be a non-zero even count within the read maximum.
return pdu[1] != 0 && pdu[1] % 2 == 0 && pdu[1] <= uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2);
case FunctionCode::READ_FILE_RECORD:
case FunctionCode::WRITE_FILE_RECORD:
return pdu[1] <= uint8_t(MAX_PDU_SIZE - 2);
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
return pdu[1] != 0 && pdu[1] % 2 == 0 && pdu[1] <= uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2);
case FunctionCode::WRITE_MULTIPLE_COILS:
case FunctionCode::WRITE_MULTIPLE_REGISTERS: {
// The response echoes start address and quantity: bound them like the request side does.
const bool bits = static_cast<FunctionCode>(pdu[0]) == FunctionCode::WRITE_MULTIPLE_COILS;
const uint16_t start_address = get_data<uint16_t>(pdu, 1);
const uint16_t quantity = get_data<uint16_t>(pdu, 3);
const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE;
return quantity != 0 && quantity <= max_quantity && (uint32_t) start_address + quantity <= 0x10000u;
}
case FunctionCode::WRITE_SINGLE_COIL:
// The response echoes the request, so the same ON/OFF constraint applies.
return (pdu[3] == 0xFF || pdu[3] == 0x00) && pdu[4] == 0x00;
default:
return true; // All other function codes validated by length alone
}
}
bool is_client_pdu_standard(const uint8_t *pdu, size_t size) {
if (client_pdu_length(pdu, size) != size)
return false;
switch (static_cast<FunctionCode>(pdu[0])) {
case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS:
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS: {
const bool bits = static_cast<FunctionCode>(pdu[0]) == FunctionCode::READ_COILS ||
static_cast<FunctionCode>(pdu[0]) == FunctionCode::READ_DISCRETE_INPUTS;
const uint16_t start_address = get_data<uint16_t>(pdu, 1);
const uint16_t quantity = get_data<uint16_t>(pdu, 3);
const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_READ : MAX_NUM_OF_REGISTERS_TO_READ;
return quantity != 0 && quantity <= max_quantity && (uint32_t) start_address + quantity <= 0x10000u;
}
case FunctionCode::WRITE_MULTIPLE_COILS:
case FunctionCode::WRITE_MULTIPLE_REGISTERS: {
const bool bits = static_cast<FunctionCode>(pdu[0]) == FunctionCode::WRITE_MULTIPLE_COILS;
const uint16_t start_address = get_data<uint16_t>(pdu, 1);
const uint16_t quantity = get_data<uint16_t>(pdu, 3);
const uint16_t max_quantity = bits ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE;
// Coils are packed 8 per data byte; registers are 2 bytes each.
const size_t expected_data_bytes = bits ? (static_cast<size_t>(quantity) + 7) / 8 : quantity * 2;
return quantity != 0 && quantity <= max_quantity && (uint32_t) start_address + quantity <= 0x10000u &&
pdu[5] == expected_data_bytes;
}
case FunctionCode::READ_FILE_RECORD:
case FunctionCode::WRITE_FILE_RECORD:
return pdu[1] <= MAX_PDU_SIZE - 2;
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: {
const uint16_t start_address_read = get_data<uint16_t>(pdu, 1);
const uint16_t quantity_read = get_data<uint16_t>(pdu, 3);
const uint16_t start_address_write = get_data<uint16_t>(pdu, 5);
const uint16_t quantity_write = get_data<uint16_t>(pdu, 7);
return quantity_read != 0 && quantity_read <= MAX_NUM_OF_REGISTERS_TO_READ && quantity_write != 0 &&
quantity_write <= MAX_NUM_OF_REGISTERS_TO_WRITE_RW &&
(uint32_t) start_address_read + quantity_read <= 0x10000u &&
(uint32_t) start_address_write + quantity_write <= 0x10000u && pdu[9] == quantity_write * 2;
}
case FunctionCode::WRITE_SINGLE_COIL:
// The one variable field in an otherwise fixed-shape PDU: the spec allows exactly ON/OFF.
return (pdu[3] == 0xFF || pdu[3] == 0x00) && pdu[4] == 0x00;
default:
return true; // All other function codes validated by length alone
}
}
+32 -6
View File
@@ -39,13 +39,39 @@ inline bool is_function_code_custom(uint8_t function_code) {
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 server response PDU based on the function code.
// If too few bytes have arrived to determine the length, returns the minimum length. `size` is the
// number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC
// bytes): only fixed header positions are interpreted, so surplus bytes are never misread.
uint16_t server_pdu_length(const uint8_t *frame, size_t size);
// Frame counterpart: address(1) + PDU + CRC(2). Passes every received byte after the address through,
// so header fields (e.g. a byte count) are interpreted as soon as they arrive.
inline uint16_t server_frame_length(const uint8_t *frame, size_t size) {
if (size < 2)
return MIN_FRAME_SIZE; // function code not received yet
return server_pdu_length(frame + 1, size - 1) + 3;
}
// 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);
// Returns the expected length of a client request PDU based on the function code.
// Same contract as server_pdu_length(): `size` is bytes available so far, may exceed the PDU.
uint16_t client_pdu_length(const uint8_t *frame, size_t size);
inline uint16_t client_frame_length(const uint8_t *frame, size_t size) {
if (size < 2)
return MIN_FRAME_SIZE; // function code not received yet
return client_pdu_length(frame + 1, size - 1) + 3;
}
// Returns true if pdu is a complete transaction whose shape is consistent with its function code.
// Unlike *_pdu_length(), `size` here is the exact PDU length: a size mismatch is non-conformant.
// Function codes with nothing variable to cross-check are validated by their fixed length alone: the
// single writes (except 0x05's value field, which must be 0x0000 or 0xFF00), mask-write and FIFO, and
// - deliberately - custom/unknown codes and exception responses, so a dispatcher can still route
// them by function code rather than reject them outright. Tests pin this contract.
bool is_server_pdu_standard(const uint8_t *pdu, size_t size);
// Client counterpart: additionally checks quantity bounds and address-range arithmetic per function code.
// The same acceptance rule applies to custom/unknown function codes.
bool is_client_pdu_standard(const uint8_t *pdu, size_t size);
// Remove before 2027.2.0
ESPDEPRECATED("Use server_pdu_payload() on the response PDU instead. Removed in 2027.2.0", "2026.8.0")
@@ -298,6 +298,17 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli
/// queues a modbus command in the send queue
void queue_command(const ModbusCommandItem &command);
/// Sends a raw payload (address byte + PDU, no CRC) with responses routed back to this controller.
/// The payload carries its own address byte, which may differ from this controller's address.
/// Deliberately shadows the deprecated ModbusClientDevice::send_raw() with identical semantics:
/// controller-level raw sends stay supported until the command machinery is replaced.
void send_raw(const std::vector<uint8_t> &payload) {
if (payload.empty()) {
this->on_not_sent(); // match the hub-level send_raw(): a refused send is always signalled
return;
}
this->parent_->send_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this);
}
/// Registers a sensor with the controller. Called by esphomes code generator
void add_sensor_item(SensorItem *item) { sensorset_.insert(item); }
/// called when a modbus response was parsed without errors
+2 -4
View File
@@ -77,10 +77,8 @@ void PZEMAC::dump_config() {
}
void PZEMAC::reset_energy_() {
std::vector<uint8_t> cmd;
cmd.push_back(this->address_);
cmd.push_back(PZEM_CMD_RESET_ENERGY);
this->send_raw(cmd);
const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY};
this->send_pdu(pdu);
}
} // namespace esphome::pzemac
+2 -4
View File
@@ -65,10 +65,8 @@ void PZEMDC::dump_config() {
}
void PZEMDC::reset_energy() {
std::vector<uint8_t> cmd;
cmd.push_back(this->address_);
cmd.push_back(PZEM_CMD_RESET_ENERGY);
this->send_raw(cmd);
const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY};
this->send_pdu(pdu);
}
} // namespace esphome::pzemdc
@@ -94,8 +94,9 @@ TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) {
EXPECT_EQ(requeued.device, &device);
// address + PDU + CRC
ASSERT_EQ(requeued.frame.size(), sizeof(READ_PDU) + 3);
EXPECT_EQ(requeued.frame.data.data()[0], 0x02);
EXPECT_EQ(0, memcmp(requeued.frame.data.data() + 1, READ_PDU, sizeof(READ_PDU)));
EXPECT_EQ(requeued.frame.address(), 0x02);
ASSERT_EQ(requeued.frame.pdu().size(), sizeof(READ_PDU));
EXPECT_EQ(0, memcmp(requeued.frame.pdu().data(), READ_PDU, sizeof(READ_PDU)));
}
// A device that declines the retry has the frame dropped.
@@ -208,4 +209,15 @@ TEST(ModbusClientHubCompat, LegacyCallbackNamesStillForward) {
EXPECT_EQ(device.legacy_not_sent_, 1);
}
// The send_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU
// 256-byte limit, so it is refused up front and signalled like any other failed send.
TEST(ModbusClientHub, OversizedPduIsRefusedWithNotSent) {
NoResponseProbeHub hub;
LegacyNameDevice device(&hub, 0x02);
std::vector<uint8_t> big(MAX_PDU_SIZE + 1, 0x41);
device.send_pdu(big);
EXPECT_EQ(device.legacy_not_sent_, 1); // on_not_sent, observed via the legacy forward
EXPECT_TRUE(hub.tx_buffer_empty());
}
} // namespace esphome::modbus::testing
@@ -83,6 +83,13 @@ TEST(ModbusClientFrameLength, WriteMultipleByteCountCapped) {
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + MAX_NUM_OF_REGISTERS_TO_WRITE * 2);
}
TEST(ModbusClientFrameLength, ReadWriteMultipleByteCountCappedAtSpecLimit) {
// FC 0x17's write byte count caps at the spec 6.17 limit of 121 registers (242 bytes), deliberately
// tighter than FC 0x10's 123, so a corrupt byte count cannot make the parser wait past the real frame.
const uint8_t pdu[] = {0x17, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0xFF}; // claims 255 bytes
EXPECT_EQ(client_pdu_length(pdu, sizeof(pdu)), 10 + MAX_NUM_OF_REGISTERS_TO_WRITE_RW * 2);
}
TEST(ModbusClientFrameLength, WriteMultipleMissingByteCount) {
const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9);
@@ -97,6 +104,124 @@ TEST(ModbusClientFrameLength, MiscFixedAndUnknown) {
EXPECT_EQ(client_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE);
}
// --- file-record length cap --------------------------------------------------
// FC 0x14/0x15 are parsed only to keep the frame parser in sync; the byte count caps at 251
// (MAX_PDU_SIZE - 2), reproducing the released frame-relative bound of MAX_FRAME_SIZE - 5.
TEST(ModbusFileRecordCap, PduLengthCapsByteCountAt251) {
const uint8_t pdu[] = {static_cast<uint8_t>(FC::READ_FILE_RECORD), 0xFF}; // claims 255 bytes
EXPECT_EQ(server_pdu_length(pdu, sizeof(pdu)), 2 + (MAX_PDU_SIZE - 2));
EXPECT_EQ(client_pdu_length(pdu, sizeof(pdu)), 2 + (MAX_PDU_SIZE - 2));
// Frame wrappers: address(1) + PDU + CRC(2) stays within the RTU 256-byte frame limit.
const uint8_t frame[] = {0x01, static_cast<uint8_t>(FC::WRITE_FILE_RECORD), 0xFF};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), MAX_FRAME_SIZE);
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), MAX_FRAME_SIZE);
}
TEST(ModbusFileRecordCap, StandardChecksAcceptUpTo251) {
// A full-length PDU at the cap: function(1) + byte count(1) + 251 data bytes = MAX_PDU_SIZE.
std::vector<uint8_t> at_cap(MAX_PDU_SIZE, 0x00);
at_cap[0] = static_cast<uint8_t>(FC::READ_FILE_RECORD);
at_cap[1] = MAX_PDU_SIZE - 2;
EXPECT_TRUE(is_server_pdu_standard(at_cap.data(), at_cap.size()));
EXPECT_TRUE(is_client_pdu_standard(at_cap.data(), at_cap.size()));
// Byte count 252 in the same 253-byte buffer: the parsed length still matches (capped), so this
// exercises the byte-count bound itself rather than the length identity.
at_cap[1] = MAX_PDU_SIZE - 1;
EXPECT_FALSE(is_server_pdu_standard(at_cap.data(), at_cap.size()));
EXPECT_FALSE(is_client_pdu_standard(at_cap.data(), at_cap.size()));
}
// --- is_client_pdu_standard / is_server_pdu_standard -------------------------
// The gatekeepers for the typed client dispatch: a PDU must be exactly its function code's standard
// shape, with byte count, quantity, and address range all consistent.
TEST(ModbusPduStandard, ClientReadAndWriteConformant) {
const uint8_t read_regs[] = {0x03, 0x01, 0x00, 0x00, 0x02};
EXPECT_TRUE(is_client_pdu_standard(read_regs, sizeof(read_regs)));
const uint8_t write_regs[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01, 0x00, 0x02};
EXPECT_TRUE(is_client_pdu_standard(write_regs, sizeof(write_regs)));
// 10 coils pack into 2 data bytes - the coil formula, not the register one.
const uint8_t write_coils[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x02, 0xFF, 0x03};
EXPECT_TRUE(is_client_pdu_standard(write_coils, sizeof(write_coils)));
}
TEST(ModbusPduStandard, ClientRejectsNonConformant) {
// Truncated: header claims 4 data bytes, only 2 present.
const uint8_t truncated[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01};
EXPECT_FALSE(is_client_pdu_standard(truncated, sizeof(truncated)));
// Byte count disagrees with quantity (2 registers need 4 bytes, header says 2).
const uint8_t inconsistent[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x02, 0x00, 0x01};
EXPECT_FALSE(is_client_pdu_standard(inconsistent, sizeof(inconsistent)));
// Coil write using the register byte-count formula (10 coils with 20 data bytes).
const uint8_t coil_as_regs[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x14, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
EXPECT_FALSE(is_client_pdu_standard(coil_as_regs, sizeof(coil_as_regs)));
// Quantity zero and quantity beyond the per-function-code maximum.
const uint8_t zero_qty[] = {0x03, 0x01, 0x00, 0x00, 0x00};
EXPECT_FALSE(is_client_pdu_standard(zero_qty, sizeof(zero_qty)));
const uint8_t too_many[] = {0x03, 0x01, 0x00, 0x00, 0x7E}; // 126 > 125
EXPECT_FALSE(is_client_pdu_standard(too_many, sizeof(too_many)));
// Address range overflow: 0xFFFF + 2 registers exceeds the 16-bit register space.
const uint8_t wraps[] = {0x03, 0xFF, 0xFF, 0x00, 0x02};
EXPECT_FALSE(is_client_pdu_standard(wraps, sizeof(wraps)));
}
TEST(ModbusPduStandard, ServerReadResponses) {
const uint8_t ok[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
EXPECT_TRUE(is_server_pdu_standard(ok, sizeof(ok)));
// Byte-count header disagrees with the actual length.
const uint8_t lying[] = {0x03, 0x06, 0x00, 0x2A, 0x01, 0x00};
EXPECT_FALSE(is_server_pdu_standard(lying, sizeof(lying)));
// An empty PDU (the on_error path) is not a standard response.
EXPECT_FALSE(is_server_pdu_standard(ok, 0));
}
TEST(ModbusPduStandard, ServerResponsesRejectDegenerateShapes) {
// A read response always carries data: byte count zero is non-conformant.
const uint8_t zero_bc[] = {0x03, 0x00};
EXPECT_FALSE(is_server_pdu_standard(zero_bc, sizeof(zero_bc)));
// Registers are 2 bytes each: an odd byte count would silently truncate a register.
const uint8_t odd_bc[] = {0x03, 0x03, 0x00, 0x01, 0x02};
EXPECT_FALSE(is_server_pdu_standard(odd_bc, sizeof(odd_bc)));
// Bit reads have no parity requirement: one packed byte is a fine coil response.
const uint8_t coil_one_byte[] = {0x01, 0x01, 0x05};
EXPECT_TRUE(is_server_pdu_standard(coil_one_byte, sizeof(coil_one_byte)));
// A write-multiple echo claiming 65535 registers written is bounded like the request side.
const uint8_t wild_echo[] = {0x10, 0x00, 0x00, 0xFF, 0xFF};
EXPECT_FALSE(is_server_pdu_standard(wild_echo, sizeof(wild_echo)));
const uint8_t ok_echo[] = {0x10, 0x00, 0x00, 0x00, 0x02};
EXPECT_TRUE(is_server_pdu_standard(ok_echo, sizeof(ok_echo)));
}
TEST(ModbusPduStandard, SingleCoilValueMustBeCanonical) {
// FC 0x05's value field allows exactly 0xFF00 (ON) and 0x0000 (OFF); anything else is non-standard.
const uint8_t on[] = {0x05, 0x00, 0x10, 0xFF, 0x00};
const uint8_t off[] = {0x05, 0x00, 0x10, 0x00, 0x00};
const uint8_t junk[] = {0x05, 0x00, 0x10, 0x12, 0x34};
EXPECT_TRUE(is_client_pdu_standard(on, sizeof(on)));
EXPECT_TRUE(is_client_pdu_standard(off, sizeof(off)));
EXPECT_FALSE(is_client_pdu_standard(junk, sizeof(junk)));
EXPECT_TRUE(is_server_pdu_standard(on, sizeof(on))); // the response echoes the request
EXPECT_FALSE(is_server_pdu_standard(junk, sizeof(junk)));
}
TEST(ModbusPduStandard, NonStandardFunctionCodesAcceptedOnLengthAlone) {
// Custom, unimplemented, and exception function codes have no standard shape to check: they are
// accepted whenever the parsed length matches, so a dispatcher can still route them by function
// code instead of having them rejected outright. This is the documented contract - see the header.
const uint8_t custom[] = {0x42}; // user-defined space; 1 byte matches the MIN_PDU_SIZE fallback
EXPECT_TRUE(is_client_pdu_standard(custom, sizeof(custom)));
EXPECT_TRUE(is_server_pdu_standard(custom, sizeof(custom)));
const uint8_t unimplemented[] = {0x07}; // READ_EXCEPTION_STATUS
EXPECT_TRUE(is_server_pdu_standard(unimplemented, sizeof(unimplemented)));
const uint8_t exception[] = {0x83, 0x02}; // exception response; length pinned to 2 bytes
EXPECT_TRUE(is_server_pdu_standard(exception, sizeof(exception)));
// The length identity still gates: extra bytes beyond the parsed fallback are non-conformant.
const uint8_t custom_long[] = {0x42, 0x01};
EXPECT_FALSE(is_client_pdu_standard(custom_long, sizeof(custom_long)));
}
// --- create_client_pdu -----------------------------------------------------
// PDU = function code + data (no address, no CRC).