[modbus] PackedBits and right-sized typed PDU builders (#17848)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Bonne Eggleston
2026-07-25 11:18:46 -10:00
committed by GitHub
co-authored by Claude Fable 5 J. Nick Koston
parent 0833e91fb5
commit a2c749c277
8 changed files with 567 additions and 77 deletions
@@ -1,5 +1,9 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <span>
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
@@ -107,6 +111,62 @@ 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
// A read request PDU is always function code(1) + start address(2) + quantity(2)
static constexpr uint16_t READ_PDU_SIZE = 5;
// A single-write PDU is always function code(1) + address(2) + value(2)
static constexpr uint16_t WRITE_SINGLE_PDU_SIZE = 5;
static constexpr uint16_t MAX_FRAME_SIZE = 256;
/** Read-only view of Modbus-packed bits: bit 0 of byte 0 is the first bit (LSB first), the layout
* coil/discrete-input values use on the wire. Bundles the bit count with the packed bytes so the
* two cannot desynchronize. The view does not own the bytes - it is only valid while they are.
* Reads (operator[]) are unchecked by design - the caller owns the bit < size() precondition, as
* with any subscript. Writes and forwarding are defensive: set() drops out-of-range bits and
* bytes() clamps to the real span, because those paths touch buffers and the wire directly.
*/
class PackedBits {
public:
PackedBits(std::span<const uint8_t> data, uint16_t count) : data_(data), count_(count) {}
/// Value of the given bit; bit must be < size().
bool operator[](size_t bit) const { return (this->data_[bit / 8] & (1 << (bit % 8))) != 0; }
/// Number of bits in the view.
uint16_t size() const { return this->count_; }
/// The underlying packed bytes: exactly ceil(size() / 8) bytes, even when the view was constructed
/// over a larger buffer - forwarding this span onto the wire can never leak trailing buffer content.
/// Clamped to the actual span so a view over a too-short buffer stays detectable instead of UB.
std::span<const uint8_t> bytes() const {
return this->data_.first(std::min<size_t>((this->count_ + 7) / 8, this->data_.size()));
}
private:
std::span<const uint8_t> data_; // must cover ceil(count_ / 8) bytes
uint16_t count_;
};
/** Mutable counterpart of PackedBits: set() writes bits in place (deliberately no proxy operator[]=).
* Converts implicitly to PackedBits for read access.
*/
class MutablePackedBits {
public:
MutablePackedBits(std::span<uint8_t> data, uint16_t count) : data_(data), count_(count) {}
bool operator[](size_t bit) const { return (this->data_[bit / 8] & (1 << (bit % 8))) != 0; }
/// Set or clear the given bit. Out-of-range bits are dropped: on the server read path the span wraps a
/// stack response buffer, so a handler looping past size() must not be able to smash the frame.
void set(size_t bit, bool value) {
if (bit >= this->count_ || bit / 8 >= this->data_.size())
return;
if (value) {
this->data_[bit / 8] |= (1 << (bit % 8));
} else {
this->data_[bit / 8] &= ~(1 << (bit % 8));
}
}
uint16_t size() const { return this->count_; }
operator PackedBits() const { return PackedBits(this->data_, this->count_); }
private:
std::span<uint8_t> data_; // must cover ceil(count_ / 8) bytes
uint16_t count_;
};
/// End of Modbus definitions
} // namespace esphome::modbus
+208 -63
View File
@@ -280,27 +280,28 @@ std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t cou
return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF);
}
StaticVector<uint8_t, MAX_PDU_SIZE> create_client_pdu(FunctionCode 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 {};
}
// 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));
pdu.push_back(first >> 8);
pdu.push_back(first >> 0);
pdu.push_back(second >> 8);
pdu.push_back(second >> 0);
}
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 {};
return pdu;
}
if (uint32_t(start_address) + number_of_entities > 0x10000u) {
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) {
@@ -308,14 +309,14 @@ StaticVector<uint8_t, MAX_PDU_SIZE> create_client_pdu(FunctionCode function_code
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 {};
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 {};
return pdu;
}
break;
case FunctionCode::READ_HOLDING_REGISTERS:
@@ -323,57 +324,201 @@ StaticVector<uint8_t, MAX_PDU_SIZE> create_client_pdu(FunctionCode function_code
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 FunctionCode::WRITE_SINGLE_COIL:
case FunctionCode::WRITE_SINGLE_REGISTER:
break; // number_of_entities is ignored for single write, so no need to validate
case FunctionCode::WRITE_MULTIPLE_COILS:
case FunctionCode::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 {};
return pdu;
}
break;
default:
ESP_LOGE(TAG, "Unsupported function code %u for client PDU creation", static_cast<unsigned int>(function_code));
return {};
ESP_LOGE(TAG, "Unsupported function code %02X for read PDU creation", static_cast<uint8_t>(function_code));
return pdu;
}
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 != FunctionCode::WRITE_SINGLE_COIL && function_code != FunctionCode::WRITE_SINGLE_REGISTER) {
pdu.push_back(number_of_entities >> 8);
pdu.push_back(number_of_entities >> 0);
}
append_pdu_header(pdu, function_code, start_address, number_of_entities);
return pdu;
}
if (is_function_code_write(static_cast<uint8_t>(function_code))) {
if (function_code == FunctionCode::WRITE_MULTIPLE_COILS ||
function_code == FunctionCode::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]);
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)
// Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(),
// create_write_registers_pdu(), etc.) which bound their inputs per spec.
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));
}
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 && uint32_t(start_address) + number_of_entities > 0x10000u) {
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 &&
((values[0] != 0xFF && values[0] != 0x00) || values[1] != 0x00)) {
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). This is the same consistency the response
// dispatch enforces via is_client_pdu_standard(), so a frame built here can never be classified
// non-standard on reply, and the spec bound keeps the PDU within capacity by construction.
// 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 ? (static_cast<size_t>(number_of_entities) + 7) / 8 : 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]);
return pdu;
}
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values) {
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
if (values.empty()) {
ESP_LOGE(TAG, "No values provided for write multiple registers, dropping request");
return pdu;
}
// Byte count is registers × 2 (per spec); bounding the register count keeps the PDU within MAX_PDU_SIZE.
if (values.size() > MAX_NUM_OF_REGISTERS_TO_WRITE) {
ESP_LOGE(TAG, "values.size() %zu exceeds maximum registers to write %u, dropping request", values.size(),
MAX_NUM_OF_REGISTERS_TO_WRITE);
return pdu;
}
if (uint32_t(start_address) + values.size() > 0x10000u) {
ESP_LOGE(TAG, "Write of %zu registers at %u runs past the 16-bit address space, dropping request", values.size(),
start_address);
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) {
auto decoded_value = decode_value(v);
pdu.push_back(decoded_value[0]);
pdu.push_back(decoded_value[1]);
}
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 (uint32_t(start_address) + count > 0x10000u) {
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 = (count + 7) / 8;
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]);
}
// Zero the unused bits of the final byte, as the spec requires
if (count % 8 != 0) {
pdu[pdu.size() - 1] &= static_cast<uint8_t>((1 << (count % 8)) - 1);
}
}
PduBuffer create_write_coils_pdu(uint16_t start_address, PackedBits bits) {
PduBuffer pdu;
build_write_coils_pdu(pdu, start_address, bits);
return pdu;
}
PduBuffer create_write_coils_pdu(uint16_t start_address, std::span<const bool> values) {
PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object)
// Bound before packing so the transient buffer below cannot overflow; the shared core validates the rest.
if (values.size() > MAX_NUM_OF_COILS_TO_WRITE) {
ESP_LOGE(TAG, "values.size() %zu exceeds maximum coils to write %u, dropping request", values.size(),
MAX_NUM_OF_COILS_TO_WRITE);
return pdu;
}
StaticVector<uint8_t, (MAX_NUM_OF_COILS_TO_WRITE + 7) / 8> packed;
for (size_t i = 0; i != values.size(); i++) {
if (i % 8 == 0)
packed.push_back(0);
if (values[i])
packed[i / 8] |= (1 << (i % 8));
}
build_write_coils_pdu(pdu, start_address,
PackedBits(std::span<const uint8_t>(packed.data(), packed.size()), values.size()));
return pdu;
}
} // namespace esphome::modbus::helpers
+77 -6
View File
@@ -350,7 +350,24 @@ inline int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueTy
*/
std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type);
/** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs.
// Named PDU buffer types: the builders' storage strategy (currently stack-allocated StaticVector,
// right-sized per shape) can be swapped in one place without touching every signature.
using PduBuffer = StaticVector<uint8_t, MAX_PDU_SIZE>;
using ReadPdu = StaticVector<uint8_t, READ_PDU_SIZE>;
using WriteSinglePdu = StaticVector<uint8_t, WRITE_SINGLE_PDU_SIZE>;
/** Create a modbus read request PDU.
* @param function_code one of READ_COILS, READ_DISCRETE_INPUTS, READ_HOLDING_REGISTERS, READ_INPUT_REGISTERS
* @param start_address coil/register/input starting address
* @param number_of_entities number of coils/registers/inputs to read
* @return PDU (function code + data, no address, no CRC); empty on invalid input
*/
ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint16_t number_of_entities);
/** Create a modbus client pdu for reading/writing single/multiple coils/register/inputs.
* Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(),
* create_write_registers_pdu(), create_write_single_register_pdu(), create_write_coils_pdu(),
* create_write_single_coil_pdu()) which bound their inputs per spec.
* @param function_code the modbus function code to use. One of:
* READ_COILS
* READ_DISCRETE_INPUTS
@@ -366,11 +383,59 @@ std::optional<int64_t> registers_to_number(const uint16_t *registers, size_t cou
* @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(FunctionCode function_code, uint16_t start_address,
uint16_t number_of_entities, const uint8_t *values = nullptr,
size_t values_len = 0);
PduBuffer create_client_pdu(FunctionCode 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) {
/** Create modbus write multiple registers command
* Function 0x10 Write Multiple Registers
* @param start_address modbus address of the first register to write
* @param values register values to write; the register count is values.size() (at most
* MAX_NUM_OF_REGISTERS_TO_WRITE, an over-long set is rejected and an empty PDU is returned).
* Any contiguous uint16_t container converts (std::vector, std::array).
* @return PDU (function code + data, no address, no CRC)
*/
PduBuffer create_write_registers_pdu(uint16_t start_address, std::span<const uint16_t> values);
/** Create modbus write single register command
* Function 0x06 Write Single Register
* @param start_address modbus address of the register to write
* @param value uint16_t value to write
* @return PDU (function code + data, no address, no CRC)
*/
WriteSinglePdu create_write_single_register_pdu(uint16_t start_address, uint16_t value);
/** Create modbus write single coil command
* Function 0x05 Write Single Coil
* @param address modbus address of the coil to write
* @param value coil value to write
* @return PDU (function code + data, no address, no CRC)
*/
WriteSinglePdu create_write_single_coil_pdu(uint16_t address, bool value);
/** Create modbus write multiple coils command
* Function 0x0F Write Multiple Coils
* @param start_address modbus address of the first coil to write
* @param values coil values to write; the coil count is values.size() (at most MAX_NUM_OF_COILS_TO_WRITE, an
* over-long set is rejected and an empty PDU is returned). Note std::vector<bool> is bit-packed and
* does not convert to a span; pass a std::array<bool, N> or other contiguous bool container.
* @return PDU (function code + data, no address, no CRC)
*/
PduBuffer create_write_coils_pdu(uint16_t start_address, std::span<const bool> values);
/** Create modbus write multiple coils command (function 0x0F) from bits packed as on the wire.
* @param start_address modbus address of the first coil to write
* @param bits PackedBits view of the coils to write (at most MAX_NUM_OF_COILS_TO_WRITE); invalid
* input returns an empty PDU
* @return PDU (function code + data, no address, no CRC)
*/
PduBuffer create_write_coils_pdu(uint16_t start_address, PackedBits bits);
/** Append a float converted to register words to any push_back container (heap-free with StaticVector).
* @param data container the register words are appended to
* @param value value to convert
* @param value_type defines if 16/32/64 bits or FP32 is used
*/
template<typename Container> void float_to_payload(Container &data, float value, SensorValueType value_type) {
int64_t val;
if (value_type_is_float(value_type)) {
@@ -379,8 +444,14 @@ inline std::vector<uint16_t> float_to_payload(float value, SensorValueType value
val = llroundf(value);
}
std::vector<uint16_t> data;
number_to_payload(data, val, value_type);
}
// Remove before 2027.2.0
ESPDEPRECATED("Use the container overload of float_to_payload() instead. Removed in 2027.2.0", "2026.8.0")
inline std::vector<uint16_t> float_to_payload(float value, SensorValueType value_type) {
std::vector<uint16_t> data;
float_to_payload(data, value, value_type);
return data;
}
@@ -98,7 +98,9 @@ inline int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueTy
ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0")
inline std::vector<uint16_t> float_to_payload(float value, SensorValueType value_type) {
return modbus::helpers::float_to_payload(value, value_type);
std::vector<uint16_t> data;
modbus::helpers::float_to_payload(data, value, value_type);
return data;
}
class ModbusController;
@@ -61,7 +61,8 @@ void ModbusNumber::control(float value) {
this->parent_->on_write_register_response(write_cmd.register_type, this->start_address, data);
});
} else {
data = modbus::helpers::float_to_payload(write_value, this->sensor_value_type);
std::vector<uint16_t> payload;
modbus::helpers::float_to_payload(payload, write_value, this->sensor_value_type);
ESP_LOGD(TAG,
"Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)",
@@ -71,10 +72,10 @@ void ModbusNumber::control(float value) {
if (this->register_count == 1 && !this->use_write_multiple_) {
// since offset is in bytes and a register is 16 bits we get the start by adding offset/2
write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset / 2,
data[0]);
payload[0]);
} else {
write_cmd = ModbusCommandItem::create_write_multiple_command(
this->parent_, this->start_address + this->offset / 2, this->register_count, data);
this->parent_, this->start_address + this->offset / 2, this->register_count, payload);
}
// publish new value
write_cmd.on_data_func = [this, write_cmd, value](modbus::EntityType register_type, uint16_t start_address,
@@ -33,12 +33,30 @@ void ModbusFloatOutput::write_state(float value) {
}
// lambda didn't set payload
if (data.empty()) {
data = modbus::helpers::float_to_payload(value, this->sensor_value_type);
modbus::helpers::float_to_payload(data, value, this->sensor_value_type);
}
ESP_LOGD(TAG, "Updating register: start address=0x%X register count=%d new value=%.02f (val=%.02f)",
this->start_address, this->register_count, value, original_value);
// The command declares register_count registers, so the payload must be exactly that many words;
// anything else would put a byte count on the wire that disagrees with the quantity field.
// number_to_payload() appends nothing for RAW, so an empty payload must be caught before data[0].
if (data.empty()) {
ESP_LOGW(TAG, "No payload was created for updating output");
return;
}
// register_count declares the READ range width - it may pull neighboring registers into one poll -
// so a write covers exactly the registers the value occupies: the quantity comes from the payload,
// never from register_count (padding to it would zero registers the user only declared for reading).
// A payload wider than the declared range means the config and the lambda disagree - drop it.
if (data.size() > this->register_count) {
ESP_LOGE(TAG, "Payload has %zu registers but register_count is %u; dropping write", data.size(),
this->register_count);
return;
}
// Create and send the write command
ModbusCommandItem write_cmd;
if (this->register_count == 1 && !this->use_write_multiple_) {
@@ -46,7 +64,7 @@ void ModbusFloatOutput::write_state(float value) {
ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0]);
} else {
write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, this->start_address + this->offset,
this->register_count, data);
data.size(), data);
}
this->parent_->queue_command(write_cmd);
}
@@ -72,13 +72,26 @@ void ModbusSelect::control(size_t index) {
return;
}
// The command declares register_count registers, so the payload must be exactly that many words:
// a value type narrower than the declared width is zero-padded (the config deliberately allows
// register_count larger than the value type). Anything else would put a byte count on the wire
// that disagrees with the quantity field, which conformant devices reject.
// register_count declares the READ range width - it may pull neighboring registers into one poll -
// so a write covers exactly the registers the value occupies: the quantity comes from the payload,
// never from register_count (padding to it would zero registers the user only declared for reading).
// A payload wider than the declared range means the config and the lambda disagree - drop it.
if (data.size() > this->register_count) {
ESP_LOGE(TAG, "Payload has %zu registers but register_count is %u; dropping write", data.size(),
this->register_count);
return;
}
const uint16_t write_address = this->start_address + this->offset / 2;
ModbusCommandItem write_cmd;
if ((this->register_count == 1) && (!this->use_write_multiple_)) {
write_cmd = ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0]);
} else {
write_cmd =
ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, this->register_count, data);
write_cmd = ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data);
}
this->parent_->queue_command(write_cmd);
@@ -1,5 +1,7 @@
#include <gtest/gtest.h>
#include <memory>
#include "esphome/components/modbus/modbus_helpers.h"
namespace esphome::modbus::helpers {
@@ -304,6 +306,31 @@ TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) {
EXPECT_TRUE(pdu.empty());
}
// The generic write path requires the data length to agree exactly with the entity count
// (registers: 2 bytes each; coils: 8 packed per byte) - the same rule the response dispatch
// enforces via is_client_pdu_standard(), so a frame built here always passes that gate.
TEST(ModbusCreateClientPdu, WriteMultipleRejectsMismatchedDataLength) {
const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16};
// 2 registers need exactly 4 data bytes.
EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, 3).empty());
EXPECT_FALSE(create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, 4).empty());
// 10 coils pack into exactly 2 data bytes - the coil formula, not the register one.
EXPECT_FALSE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 10, values, 2).empty());
EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 10, values, 4).empty());
}
TEST(ModbusCreateClientPdu, WriteCoilsUseTheCoilLimitNotTheRegisterLimit) {
// 200 coils: above the 123-register write limit but well within the 1968-coil limit; 25 data bytes.
std::vector<uint8_t> values(25, 0xAA);
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 200, values.data(), values.size());
ASSERT_FALSE(pdu.empty());
EXPECT_EQ(pdu[5], 25); // byte count uses the coil formula
EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size())); // builder output passes the validator
// Builder and validator agree at the top of the range too: 1969 coils rejected.
std::vector<uint8_t> big((1969 + 7) / 8, 0x00);
EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 1969, big.data(), big.size()).empty());
}
TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) {
const std::vector<uint8_t> data{0x12, 0x34};
EXPECT_FALSE(payload_to_number(std::span<const uint8_t>(data), SensorValueType::U_WORD, 2, 0xFFFFFFFF).has_value());
@@ -354,6 +381,159 @@ TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) {
EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value());
}
// --- typed builders ----------------------------------------------------------
TEST(ModbusTypedBuilders, ReadPduWireBytes) {
auto pdu = create_read_pdu(FC::READ_HOLDING_REGISTERS, 0x0102, 3);
const std::vector<uint8_t> expected{0x03, 0x01, 0x02, 0x00, 0x03};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size()));
// Reads that run past the 16-bit address space are refused.
EXPECT_TRUE(create_read_pdu(FC::READ_HOLDING_REGISTERS, 0xFFFF, 2).empty());
}
TEST(ModbusTypedBuilders, WriteSinglePduWireBytes) {
auto reg = create_write_single_register_pdu(0x0010, 0xABCD);
const std::vector<uint8_t> expected_reg{0x06, 0x00, 0x10, 0xAB, 0xCD};
EXPECT_EQ(std::vector<uint8_t>(reg.begin(), reg.end()), expected_reg);
EXPECT_TRUE(is_client_pdu_standard(reg.data(), reg.size()));
auto coil_on = create_write_single_coil_pdu(0x0011, true);
auto coil_off = create_write_single_coil_pdu(0x0011, false);
const std::vector<uint8_t> expected_on{0x05, 0x00, 0x11, 0xFF, 0x00};
const std::vector<uint8_t> expected_off{0x05, 0x00, 0x11, 0x00, 0x00};
EXPECT_EQ(std::vector<uint8_t>(coil_on.begin(), coil_on.end()), expected_on);
EXPECT_EQ(std::vector<uint8_t>(coil_off.begin(), coil_off.end()), expected_off);
EXPECT_TRUE(is_client_pdu_standard(coil_on.data(), coil_on.size()));
EXPECT_TRUE(is_client_pdu_standard(coil_off.data(), coil_off.size()));
}
TEST(ModbusTypedBuilders, WriteRegistersPduWireBytes) {
const uint16_t values[] = {0x000B, 0x0016};
auto pdu = create_write_registers_pdu(0x0000, 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);
EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size()));
// Writes that run past the 16-bit address space are refused.
EXPECT_TRUE(create_write_registers_pdu(0xFFFF, values).empty());
}
TEST(ModbusTypedBuilders, WriteRegistersPduRejectsOverLimit) {
std::vector<uint16_t> values(MAX_NUM_OF_REGISTERS_TO_WRITE + 1, 0xAAAA);
EXPECT_TRUE(create_write_registers_pdu(0x0000, values).empty());
values.pop_back();
EXPECT_FALSE(create_write_registers_pdu(0x0000, values).empty());
}
TEST(ModbusTypedBuilders, FloatToPayloadAppendsToExistingContent) {
// The container overload appends - the semantic every migrated caller relies on when a lambda
// has already put words into the buffer.
std::vector<uint16_t> data{0x1234};
float_to_payload(data, 1.0f, SensorValueType::U_WORD);
ASSERT_EQ(data.size(), 2u);
EXPECT_EQ(data[0], 0x1234);
EXPECT_EQ(data[1], 0x0001);
}
TEST(ModbusCreateClientPdu, ExceptionFlaggedWriteCodesRejected) {
// is_function_code_write() masks the exception bit; the builder must not.
const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16};
EXPECT_TRUE(create_client_pdu(FunctionCode(0x90), 0x0000, 2, values, 4).empty());
EXPECT_TRUE(create_client_pdu(FunctionCode(0x85), 0x0000, 1, values, 2).empty());
}
TEST(ModbusTypedBuilders, BoolSpanCoilBuilderRejectsOverLimit) {
// This early guard is what keeps the 246-byte packing buffer from overflowing - the shared core's
// identical check runs after packing, so it cannot protect it.
auto big = std::make_unique<bool[]>(MAX_NUM_OF_COILS_TO_WRITE + 1);
EXPECT_TRUE(create_write_coils_pdu(0, std::span<const bool>(big.get(), MAX_NUM_OF_COILS_TO_WRITE + 1)).empty());
}
TEST(ModbusCreateClientPdu, SingleCoilValueValidated) {
const uint8_t on[] = {0xFF, 0x00};
const uint8_t junk[] = {0x01, 0x00};
EXPECT_FALSE(create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, on, 2).empty());
EXPECT_TRUE(create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, junk, 2).empty());
}
// --- create_write_coils_pdu (packed) ---------------------------------------
TEST(ModbusWriteCoilsPacked, MatchesBoolBuilder) {
const bool coils[] = {true, false, true, true, false, false, true, false, true, true};
uint8_t packed[] = {0b01001101, 0b00000011};
auto from_bools = create_write_coils_pdu(0x13, coils);
auto from_packed = create_write_coils_pdu(0x13, PackedBits(packed, 10));
ASSERT_EQ(from_packed.size(), from_bools.size());
EXPECT_EQ(0, memcmp(from_packed.data(), from_bools.data(), from_bools.size()));
}
TEST(ModbusWriteCoilsPacked, MasksUnusedTrailingBits) {
uint8_t packed[] = {0xFF};
auto pdu = create_write_coils_pdu(0, PackedBits(packed, 3));
ASSERT_EQ(pdu.size(), 7u);
EXPECT_EQ(pdu[6], 0x07);
}
TEST(ModbusWriteCoilsPacked, RejectsShortBufferAndZeroCount) {
uint8_t packed[] = {0xFF};
EXPECT_TRUE(create_write_coils_pdu(0, PackedBits(packed, 9)).empty()); // needs 2 bytes
EXPECT_TRUE(create_write_coils_pdu(0, PackedBits(packed, 0)).empty());
}
TEST(ModbusHelpersTest, PackedBitsReadsLsbFirst) {
const uint8_t packed[] = {0x0D, 0x03}; // bits 0,2,3 and 8,9
PackedBits bits(packed, 11);
EXPECT_EQ(bits.size(), 11u);
EXPECT_TRUE(bits[0]);
EXPECT_FALSE(bits[1]);
EXPECT_TRUE(bits[2]);
EXPECT_TRUE(bits[3]);
EXPECT_FALSE(bits[7]);
EXPECT_TRUE(bits[8]);
EXPECT_TRUE(bits[9]);
EXPECT_FALSE(bits[10]);
EXPECT_EQ(bits.bytes().size(), 2u);
}
TEST(ModbusHelpersTest, MutablePackedBitsSetsAndClears) {
uint8_t packed[2] = {0x00, 0xFF};
MutablePackedBits bits(packed, 16);
bits.set(0, true);
bits.set(3, true);
bits.set(9, false);
EXPECT_EQ(packed[0], 0x09); // bits 0 and 3
EXPECT_EQ(packed[1], 0xFD); // bit 9 (bit 1 of byte 1) cleared
}
TEST(ModbusHelpersTest, MutablePackedBitsRoundTripAndConversion) {
const bool original[] = {true, true, false, true, false, false, false, false, true, false, true};
constexpr uint16_t count = sizeof(original);
uint8_t packed[(count + 7) / 8] = {};
MutablePackedBits out(packed, count);
for (uint16_t i = 0; i != count; i++)
out.set(i, original[i]);
PackedBits view = out; // implicit conversion to the read-only view
ASSERT_EQ(view.size(), count);
for (uint16_t i = 0; i != count; i++)
EXPECT_EQ(view[i], original[i]) << "bit " << i;
}
TEST(ModbusHelpersTest, PackedBitsViewContractsEnforced) {
uint8_t buf[8] = {};
PackedBits view(buf, 10); // 10 bits -> 2 bytes, over an 8-byte buffer
EXPECT_EQ(view.bytes().size(), 2u);
MutablePackedBits bits(std::span<uint8_t>(buf, 2), 10);
bits.set(9, true); // in range: lands in byte 1
bits.set(10, true); // out of range: dropped
bits.set(300, true); // far out of range: dropped, no write past the span
MutablePackedBits short_bits(std::span<uint8_t>(buf, 1), 10); // contract-violating: 10 bits over 1 byte
short_bits.set(9, false); // within count_ but past the span: dropped (would clear bit 9 set above)
EXPECT_EQ(buf[1], 0x02);
for (size_t i = 2; i < sizeof(buf); i++)
EXPECT_EQ(buf[i], 0) << "byte " << i;
}
// server_pdu_payload() must never classify an exception PDU as a read: [fc|0x80, code] is 2 bytes, and a
// read-offset of 2 would return an empty span, losing the exception code. The payload of an exception PDU
// is the exception code byte, for reads and writes alike.