Files
esphome/esphome/components/modbus/modbus_helpers.cpp
T

616 lines
29 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "modbus_helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
namespace esphome::modbus::helpers {
static const char *const TAG = "modbus_helpers";
// A quantity/address pair is standard when the quantity is non-zero, within the per-table maximum,
// and the range [start_address, start_address + quantity) stays inside the 16-bit address space.
// Non-logging twin of register_block_in_range(): the same three predicates for the parser side, taking a
// uint16_t quantity. register_block_in_range() is the builder-side variant that also logs which half failed.
static bool quantity_in_range(uint16_t start_address, uint16_t quantity, uint16_t max_quantity) {
return quantity != 0 && quantity <= max_quantity && address_range_fits(start_address, quantity);
}
// The spec allows exactly ON (0xFF00) and OFF (0x0000) for a single-coil value, on the request and
// on its echoed response alike.
static bool is_canonical_coil_value(uint8_t high_byte, uint8_t low_byte) {
return (high_byte == 0xFF || high_byte == 0x00) && low_byte == 0x00;
}
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[0])) {
case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS:
// function(1) + byte count(1) + packed coil bytes
return 2 + (size > 1 ? std::min(frame[1], uint8_t(packed_bit_bytes(MAX_NUM_OF_COILS_TO_READ))) : 0);
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS:
// function(1) + byte count(1) + register 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 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:
// 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 7; // function(1) + reference address(2) + AND mask(2) + OR mask(2)
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
// 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:
// function(1) + fifo address(2)
return 3;
default:
return MIN_PDU_SIZE; // unknown length
}
}
uint16_t client_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; // never a valid request; sized like the exception reply so the CRC fails at once
}
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:
// function(1) + start address(2) + quantity(2)
case FunctionCode::WRITE_SINGLE_COIL:
case FunctionCode::WRITE_SINGLE_REGISTER:
return 5; // function(1) + output/register address(2) + value(2)
case FunctionCode::WRITE_MULTIPLE_COILS:
// function(1) + start address(2) + quantity(2) + byte count(1) + packed coil data (8 coils per byte).
return 6 + (size > 5 ? std::min(frame[5], uint8_t(packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE))) : 0);
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
// function(1) + start address(2) + quantity(2) + byte count(1) + register data (2 bytes per register).
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:
// 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 7; // function(1) + reference address(2) + AND mask(2) + OR mask(2)
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
// 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:
// function(1) + fifo address(2)
return 3;
default:
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;
const auto function_code = static_cast<FunctionCode>(pdu[0]);
switch (function_code) {
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(packed_bit_bytes(MAX_NUM_OF_COILS_TO_READ));
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 = function_code == 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_in_range(start_address, quantity, max_quantity);
}
case FunctionCode::WRITE_SINGLE_COIL:
// The response echoes the request, so the same ON/OFF constraint applies.
return is_canonical_coil_value(pdu[3], pdu[4]);
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;
const auto function_code = static_cast<FunctionCode>(pdu[0]);
switch (function_code) {
case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS:
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS: {
const bool bits =
function_code == FunctionCode::READ_COILS || function_code == 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_in_range(start_address, quantity, max_quantity);
}
case FunctionCode::WRITE_MULTIPLE_COILS:
case FunctionCode::WRITE_MULTIPLE_REGISTERS: {
const bool bits = function_code == 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 ? packed_bit_bytes(quantity) : quantity * 2;
return quantity_in_range(start_address, quantity, max_quantity) && 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_in_range(start_address_read, quantity_read, MAX_NUM_OF_REGISTERS_TO_READ) &&
quantity_in_range(start_address_write, quantity_write, MAX_NUM_OF_REGISTERS_TO_WRITE_RW) &&
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 is_canonical_coil_value(pdu[3], pdu[4]);
default:
return true; // All other function codes validated by length alone
}
}
static size_t required_payload_size(SensorValueType sensor_value_type) {
switch (sensor_value_type) {
case SensorValueType::U_WORD:
case SensorValueType::U_WORD_S:
case SensorValueType::S_WORD:
case SensorValueType::S_WORD_S:
return 2;
case SensorValueType::U_DWORD:
case SensorValueType::FP32:
case SensorValueType::U_DWORD_R:
case SensorValueType::FP32_R:
case SensorValueType::S_DWORD:
case SensorValueType::S_DWORD_R:
return 4;
case SensorValueType::U_QWORD:
case SensorValueType::S_QWORD:
case SensorValueType::U_QWORD_R:
case SensorValueType::S_QWORD_R:
return 8;
case SensorValueType::RAW:
default:
return 0;
}
}
void log_unsupported_value_type(SensorValueType value_type) {
ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast<uint16_t>(value_type));
}
std::optional<int64_t> payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type,
uint8_t offset, uint32_t bitmask) {
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
// a malformed or misconfigured frame still produces an error log.
if (static_cast<size_t>(offset) > size) {
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), size);
return std::nullopt;
}
const size_t required_size = required_payload_size(sensor_value_type);
if (required_size == 0) {
return value;
}
if (size - offset < required_size) {
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), size, required_size);
return std::nullopt;
}
switch (sensor_value_type) {
case SensorValueType::U_WORD:
value = mask_and_shift_by_rightbit(get_data<uint16_t>(data, offset), bitmask); // default is 0xFFFF ;
break;
case SensorValueType::U_WORD_S: {
uint16_t word = byteswap(get_data<uint16_t>(data, offset));
value = mask_and_shift_by_rightbit(word, bitmask);
break;
}
case SensorValueType::U_DWORD:
case SensorValueType::FP32:
value = get_data<uint32_t>(data, offset);
value = mask_and_shift_by_rightbit((uint32_t) value, bitmask);
break;
case SensorValueType::U_DWORD_R:
case SensorValueType::FP32_R:
value = get_data<uint32_t>(data, offset);
value = static_cast<uint32_t>(value & 0xFFFF) << 16 | (value & 0xFFFF0000) >> 16;
value = mask_and_shift_by_rightbit((uint32_t) value, bitmask);
break;
case SensorValueType::S_WORD:
value = mask_and_shift_by_rightbit(get_data<int16_t>(data, offset), bitmask); // default is 0xFFFF ;
break;
case SensorValueType::S_WORD_S: {
uint16_t word = byteswap(get_data<uint16_t>(data, offset));
value = mask_and_shift_by_rightbit(static_cast<int16_t>(word), bitmask);
break;
}
case SensorValueType::S_DWORD:
value = mask_and_shift_by_rightbit(get_data<int32_t>(data, offset), bitmask);
break;
case SensorValueType::S_DWORD_R: {
value = get_data<uint32_t>(data, offset);
// Currently the high word is at the low position
// the sign bit is therefore at low before the switch
uint32_t sign_bit = (value & 0x8000) << 16;
value = mask_and_shift_by_rightbit(
static_cast<int32_t>(((value & 0x7FFF) << 16 | (value & 0xFFFF0000) >> 16) | sign_bit), bitmask);
} break;
case SensorValueType::U_QWORD:
case SensorValueType::S_QWORD:
// Ignore bitmask for QWORD
value = get_data<uint64_t>(data, offset);
break;
case SensorValueType::U_QWORD_R:
case SensorValueType::S_QWORD_R: {
// Ignore bitmask for QWORD
uint64_t tmp = get_data<uint64_t>(data, offset);
value = (tmp << 48) | (tmp >> 48) | ((tmp & 0xFFFF0000) << 16) | ((tmp >> 16) & 0xFFFF0000);
} break;
case SensorValueType::RAW:
default:
break;
}
return value;
}
std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type) {
const size_t required_size = required_payload_size(sensor_value_type);
if (required_size == 0) {
return 0; // RAW/unsupported: nothing to read
}
const size_t required_words = required_size / 2;
if (required_words > count) {
ESP_LOGE(TAG, "not enough registers for value type=%u count=%zu required=%zu",
static_cast<unsigned int>(sensor_value_type), count, required_words);
return std::nullopt;
}
// Serialize the needed words back to big-endian bytes and reuse the audited byte decoder so the
// sign-extension behaviour stays identical to the wire path.
uint8_t bytes[8]; // at most 4 registers (QWORD)
for (size_t i = 0; i < required_words; i++) {
uint16_t reg = registers[i];
bytes[i * 2] = static_cast<uint8_t>(reg >> 8);
bytes[i * 2 + 1] = static_cast<uint8_t>(reg & 0xFF);
}
return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF);
}
// Append a 16-bit value to a PDU in big-endian (wire) byte order.
template<size_t CAP> static void append_pdu_word(StaticVector<uint8_t, CAP> &pdu, uint16_t value) {
pdu.push_back(value >> 8);
pdu.push_back(value >> 0);
}
// Every request PDU opens with the same 5-byte layout: function code, then two big-endian 16-bit
// fields (start address + quantity for reads and multi-writes, address + value for single writes).
template<size_t CAP>
static void append_pdu_header(StaticVector<uint8_t, CAP> &pdu, FunctionCode function_code, uint16_t first,
uint16_t second) {
pdu.push_back(static_cast<uint8_t>(function_code));
append_pdu_word(pdu, first);
append_pdu_word(pdu, second);
}
// Zero the unused bits of a multi-coil write's final data byte, as the spec requires. Kept in one
// place so the generic and typed coil builders produce identical wire bytes for the same write.
// The caller must pass a span whose LAST byte is the final packed-bit byte - both builders pass the
// whole PDU, which qualifies because the coil data is always the PDU's tail.
static void mask_trailing_pad_bits(std::span<uint8_t> data, uint16_t bit_count) {
if (data.empty() || bit_count % 8 == 0)
return;
data.back() &= static_cast<uint8_t>((1 << (bit_count % 8)) - 1);
}
ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities) {
ReadPdu pdu; // declared before every return so NRVO fires (all paths return the same object)
if (number_of_entities == 0) {
ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast<uint8_t>(function_code));
return pdu;
}
if (!address_range_fits(start_address, number_of_entities)) {
ESP_LOGE(TAG, "Read of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities,
start_address);
return pdu;
}
switch (function_code) {
case FunctionCode::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 pdu;
}
break;
case FunctionCode::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 pdu;
}
break;
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::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 pdu;
}
break;
default:
ESP_LOGE(TAG, "Unsupported function code %02X for read PDU creation", static_cast<uint8_t>(function_code));
return pdu;
}
append_pdu_header(pdu, function_code, start_address, number_of_entities);
return pdu;
}
PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities,
const uint8_t *values, size_t values_len) {
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
if (is_function_code_read_only(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));
}
auto read_pdu = create_read_pdu(function_code, start_address, number_of_entities);
pdu.assign(read_pdu.begin(), read_pdu.end());
return pdu;
}
// Exact codes only: is_function_code_write() masks the exception bit, which would let the
// exception-flagged forms (0x85/0x86/0x8F/0x90) build a request announcing itself as an exception.
const bool is_single =
function_code == FunctionCode::WRITE_SINGLE_COIL || function_code == FunctionCode::WRITE_SINGLE_REGISTER;
const bool is_multi =
function_code == FunctionCode::WRITE_MULTIPLE_COILS || function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS;
if (!is_single && !is_multi) {
ESP_LOGE(TAG, "Unsupported function code %02X for client PDU creation", static_cast<uint8_t>(function_code));
return pdu;
}
// Generic write builder: raw caller-supplied bytes, so we can only guard against the PDU byte capacity here.
if (values == nullptr || values_len == 0) {
ESP_LOGE(TAG, "No values provided for write function code %02X", static_cast<uint8_t>(function_code));
return pdu;
}
if (number_of_entities == 0) {
ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast<uint8_t>(function_code));
return pdu;
}
// number_of_entities is ignored for single write, so only validate it for the multiple variants.
// The bound is per function code (coils pack 8 per byte, so their quantity limit is far higher) -
// the same limits is_client_pdu_standard() accepts, so builder and validator agree.
const uint16_t max_entities =
function_code == FunctionCode::WRITE_MULTIPLE_COILS ? MAX_NUM_OF_COILS_TO_WRITE : MAX_NUM_OF_REGISTERS_TO_WRITE;
if (!is_single && number_of_entities > max_entities) {
ESP_LOGE(TAG, "number_of_entities %u exceeds maximum %u for function code %02X", number_of_entities, max_entities,
static_cast<uint8_t>(function_code));
return pdu;
}
if (!is_single && !address_range_fits(start_address, number_of_entities)) {
ESP_LOGE(TAG, "Write of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities,
start_address);
return pdu;
}
if (is_single) {
// Write single register or coil: the two value bytes are the header's second field.
if (values_len < 2) {
ESP_LOGE(TAG, "values_len %zu too small for write-single command (need 2), dropping request", values_len);
return pdu;
}
// The spec allows exactly ON (0xFF00) and OFF (0x0000) for a single-coil write - the same rule
// is_client_pdu_standard() enforces, so a built frame cannot be misclassified on reply.
if (function_code == FunctionCode::WRITE_SINGLE_COIL && !is_canonical_coil_value(values[0], values[1])) {
ESP_LOGE(TAG, "Invalid single-coil value %02X%02X (must be FF00 or 0000), dropping request", values[0],
values[1]);
return pdu;
}
append_pdu_header(pdu, function_code, start_address, uint16_t((values[0] << 8) | values[1]));
return pdu;
}
// The quantity is spec-bounded above, so the data length just has to agree with it exactly
// (registers are 2 bytes each, coils pack 8 per byte).
// Checked before the header append: a failed check must return an empty PDU, not a 5-byte partial one.
const bool bits = function_code == FunctionCode::WRITE_MULTIPLE_COILS;
const size_t expected_len = bits ? packed_bit_bytes(number_of_entities) : static_cast<size_t>(number_of_entities) * 2;
if (values_len != expected_len) {
ESP_LOGE(TAG, "values_len %zu does not match %u entities (expected %zu) for function code %02X, dropping request",
values_len, number_of_entities, expected_len, static_cast<uint8_t>(function_code));
return pdu;
}
append_pdu_header(pdu, function_code, start_address, number_of_entities);
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]);
if (bits)
mask_trailing_pad_bits(pdu, number_of_entities);
return pdu;
}
// Validate one register block for a client builder: a non-zero quantity within max_quantity that does not
// run past the 16-bit address space (register count × 2 stays within MAX_PDU_SIZE as a result). On failure
// it logs the reason and returns false, on which the caller returns an empty PDU. `role` names the block in
// the log ("Read"/"Write"). Logging twin of quantity_in_range(): the same three predicates, split so each
// failure names its reason, and taking size_t so an oversize span is caught before any narrowing.
static bool register_block_in_range(const LogString *role, uint16_t start_address, size_t quantity,
uint16_t max_quantity) {
if (quantity == 0 || quantity > max_quantity) {
ESP_LOGE(TAG, "%s count %zu out of range [1, %u], dropping request", LOG_STR_ARG(role), quantity, max_quantity);
return false;
}
if (!address_range_fits(start_address, quantity)) {
ESP_LOGE(TAG, "%s of %zu registers at %u runs past the 16-bit address space, dropping request", LOG_STR_ARG(role),
quantity, start_address);
return false;
}
return true;
}
// The ceiling comes from the buffer itself: push_back() drops silently, so a bound wider than the buffer
// would put a truncated frame on the wire.
template<typename Pdu> static Pdu build_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
constexpr auto max_registers = static_cast<uint16_t>((Pdu::capacity() - WRITE_MULTIPLE_HEADER_SIZE) / 2);
Pdu pdu; // declared before every return so NRVO fires (all paths return the same object)
if (!register_block_in_range(LOG_STR("Write"), start_address, values.size(), max_registers)) {
return pdu;
}
append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_REGISTERS, start_address, values.size());
pdu.push_back(static_cast<uint8_t>(values.size() * 2)); // byte count
for (auto v : values) {
append_pdu_word(pdu, v);
}
return pdu;
}
static_assert((PduBuffer::capacity() - WRITE_MULTIPLE_HEADER_SIZE) / 2 == MAX_NUM_OF_REGISTERS_TO_WRITE,
"a full-frame PDU must hold exactly MAX_NUM_OF_REGISTERS_TO_WRITE registers");
static_assert((WriteFewRegistersPdu::capacity() - WRITE_MULTIPLE_HEADER_SIZE) / 2 == MAX_FEW_REGISTERS,
"the small write buffer must hold exactly MAX_FEW_REGISTERS registers");
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
return build_write_registers_pdu<PduBuffer>(start_address, values);
}
WriteFewRegistersPdu create_write_few_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
return build_write_registers_pdu<WriteFewRegistersPdu>(start_address, values);
}
PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count,
uint16_t write_start_address,
std::span<const uint16_t> write_values) {
PduBuffer pdu;
if (!register_block_in_range(LOG_STR("Read"), read_start_address, read_count, MAX_NUM_OF_REGISTERS_TO_READ)) {
return pdu;
}
if (!register_block_in_range(LOG_STR("Write"), write_start_address, write_values.size(),
MAX_NUM_OF_REGISTERS_TO_WRITE_RW)) {
return pdu;
}
// fc + read start(2) + read qty(2) + write start(2) + write qty(2) + write byte count(1) + write values.
const auto write_count = static_cast<uint16_t>(write_values.size());
pdu.push_back(static_cast<uint8_t>(FunctionCode::READ_WRITE_MULTIPLE_REGISTERS));
append_pdu_word(pdu, read_start_address);
append_pdu_word(pdu, read_count);
append_pdu_word(pdu, write_start_address);
append_pdu_word(pdu, write_count);
pdu.push_back(static_cast<uint8_t>(write_count * 2)); // byte count
for (auto v : write_values) {
append_pdu_word(pdu, v);
}
return pdu;
}
WriteSinglePdu create_write_single_register_pdu(uint16_t start_address, uint16_t value) {
WriteSinglePdu pdu;
append_pdu_header(pdu, FunctionCode::WRITE_SINGLE_REGISTER, start_address, value);
return pdu;
}
WriteSinglePdu create_write_single_coil_pdu(uint16_t address, bool value) {
WriteSinglePdu pdu;
append_pdu_header(pdu, FunctionCode::WRITE_SINGLE_COIL, address, value ? 0xFF00 : 0x0000);
return pdu;
}
// Shared core for the two coil-write overloads: validates, then builds into the caller's named
// pdu (left empty on failure). Each overload's returns all name one local, so NRVO fires.
static void build_write_coils_pdu(PduBuffer &pdu, uint16_t start_address, PackedBits bits) {
const uint16_t count = bits.size();
const std::span<const uint8_t> packed_bits = bits.bytes();
if (count == 0) {
ESP_LOGE(TAG, "No coils requested for write multiple coils, dropping request");
return;
}
if (count > MAX_NUM_OF_COILS_TO_WRITE) {
ESP_LOGE(TAG, "count %u exceeds maximum coils to write %u, dropping request", count, MAX_NUM_OF_COILS_TO_WRITE);
return;
}
if (!address_range_fits(start_address, count)) {
ESP_LOGE(TAG, "Write of %u coils at %u runs past the 16-bit address space, dropping request", count, start_address);
return;
}
const size_t byte_count = packed_bit_bytes(count);
if (packed_bits.size() < byte_count) {
ESP_LOGE(TAG, "packed_bits (%zu bytes) does not cover %u coils (%zu bytes), dropping request", packed_bits.size(),
count, byte_count);
return;
}
append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_COILS, start_address, count);
pdu.push_back(static_cast<uint8_t>(byte_count));
for (size_t i = 0; i != byte_count; i++) {
pdu.push_back(packed_bits[i]);
}
mask_trailing_pad_bits(pdu, count);
}
PduBuffer create_write_coils_pdu(uint16_t start_address, PackedBits bits) {
PduBuffer pdu;
build_write_coils_pdu(pdu, start_address, bits);
return pdu;
}
// Shared by the two bool-container overloads: both index the same way, so the packing is written once.
template<typename BoolContainer>
static PduBuffer create_write_coils_pdu_from_bools(uint16_t start_address, const BoolContainer &values) {
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
const size_t count = values.size();
// Bound before packing so the transient buffer below cannot overflow; the shared core validates the rest.
if (count > MAX_NUM_OF_COILS_TO_WRITE) {
ESP_LOGE(TAG, "values.size() %zu exceeds maximum coils to write %u, dropping request", count,
MAX_NUM_OF_COILS_TO_WRITE);
return pdu;
}
CoilPackBuffer packed;
pack_bits(packed, values);
build_write_coils_pdu(pdu, start_address, PackedBits(std::span<const uint8_t>(packed.data(), packed.size()), count));
return pdu;
}
PduBuffer create_write_coils_pdu(uint16_t start_address, std::span<const bool> values) {
return create_write_coils_pdu_from_bools(start_address, values);
}
PduBuffer create_write_coils_pdu(uint16_t start_address, const std::vector<bool> &values) {
return create_write_coils_pdu_from_bools(start_address, values);
}
} // namespace esphome::modbus::helpers