mirror of
https://github.com/esphome/esphome.git
synced 2026-08-30 17:46:01 +00:00
[modbus] Hub and helpers cleanup; tighten queue_pdu validation (#18847)
This commit is contained in:
@@ -89,9 +89,8 @@ _WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17})
|
||||
|
||||
def is_function_code_write(function_code: int) -> bool:
|
||||
"""True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first,
|
||||
so an exception-flagged code still classifies by its base code - stricter than the runtime hub,
|
||||
whose classify() treats an exception-flagged code as a read. Keep in sync with
|
||||
modbus::helpers::is_function_code_write()."""
|
||||
so an exception-flagged code still classifies by its base code (the runtime hub never queues one:
|
||||
queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write()."""
|
||||
return function_code & 0x7F in _WRITE_FUNCTION_CODES
|
||||
|
||||
|
||||
|
||||
@@ -10,17 +10,12 @@ namespace esphome::modbus {
|
||||
|
||||
static const char *const TAG = "modbus";
|
||||
|
||||
// Maximum bytes to log for Modbus frames (truncated if larger)
|
||||
static constexpr size_t MODBUS_MAX_LOG_BYTES = 64;
|
||||
|
||||
// Approximate bits per character on the wire (depends on parity/stop bit config)
|
||||
static constexpr uint32_t MODBUS_BITS_PER_CHAR = 11;
|
||||
// Milliseconds per second
|
||||
static constexpr uint32_t MS_PER_SEC = 1000;
|
||||
|
||||
// Shortest gap between two "no device accepted broadcast" warnings
|
||||
static constexpr uint32_t UNACCEPTED_BROADCAST_WARN_INTERVAL_MS = 60 * MS_PER_SEC;
|
||||
|
||||
void Modbus::setup() {
|
||||
if (this->flow_control_pin_ != nullptr) {
|
||||
this->flow_control_pin_->setup();
|
||||
@@ -43,10 +38,7 @@ void Modbus::setup() {
|
||||
}
|
||||
|
||||
void Modbus::loop() {
|
||||
// Receive any available bytes from UART
|
||||
this->receive_bytes_();
|
||||
|
||||
// Parse bytes into frames and process them
|
||||
this->parse_modbus_frames();
|
||||
}
|
||||
|
||||
@@ -55,7 +47,7 @@ void ModbusClientHub::loop() {
|
||||
// never times out an entry whose pending count has not been drained. No-op when nothing is owed.
|
||||
this->sweep_();
|
||||
|
||||
this->Modbus::loop(); // receive bytes and parse frames
|
||||
this->Modbus::loop();
|
||||
|
||||
// Send-wait watchdog: only the cheap time check runs at loop rate; expire_waiting_() looks the
|
||||
// entry up and holds off if the response has started arriving.
|
||||
@@ -104,11 +96,8 @@ bool Modbus::timeout_() {
|
||||
}
|
||||
|
||||
int32_t Modbus::tx_delay_remaining() {
|
||||
// We use millis() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps
|
||||
// It's critical in all timestamp comparisons that the left timestamp comes before the right one in time
|
||||
// If we use a cached value in place of millis() and last_modbus_byte_ is updated inside our loop
|
||||
// then the comparison is backwards (small negative which wraps to large positive) and will cause a false timeout
|
||||
// So in this component we don't use any cached timestamp values to avoid these annoying bugs
|
||||
// millis() here and everywhere in this component, never a cached loop timestamp: a cached "now" can
|
||||
// predate last_modbus_byte_, and the unsigned subtraction then wraps huge and forces a false timeout.
|
||||
const uint32_t now = millis();
|
||||
return std::max({(int32_t) 0,
|
||||
(int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ - (now - this->last_send_)),
|
||||
@@ -124,22 +113,13 @@ int32_t ModbusClientHub::tx_delay_remaining() {
|
||||
}
|
||||
|
||||
bool Modbus::tx_blocked() {
|
||||
// We block transmission in any of these cases:
|
||||
// 1. There are bytes in the UART Rx buffer
|
||||
// 2. There are bytes in our Rx buffer
|
||||
// 3. The last sent byte isn't more than tx_delay ms ago (i.e. wait to tell receivers that our previous Tx is done)
|
||||
// 4. The last received byte isn't more than tx_delay ms ago (i.e. wait to be sure there isn't more Rx coming)
|
||||
// N.B. We allow a small delay (MODBUS_TX_MAX_DELAY_MS) to avoid looping on small delays. This gets handled by
|
||||
// send_frame_.
|
||||
// Blocked while any rx bytes are pending, or within tx_delay of the last byte in either direction
|
||||
// (receivers must see our previous tx as done, and more rx may be coming). A remaining delay up to
|
||||
// MODBUS_TX_MAX_DELAY_MS doesn't block - send_frame_ absorbs it instead of looping on small waits.
|
||||
return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_MS;
|
||||
}
|
||||
|
||||
bool ModbusClientHub::tx_blocked() {
|
||||
// We block transmission in any of these case:
|
||||
// 1. We're waiting for a response (a waiting entry: WAITING/INTERRUPTED/WAITING_RETIRED/INTERRUPTED_RETIRED)
|
||||
// 2. Any of the base class tx_blocked conditions
|
||||
return this->waiting_for_response_ || this->Modbus::tx_blocked();
|
||||
}
|
||||
bool ModbusClientHub::tx_blocked() { return this->waiting_for_response_ || this->Modbus::tx_blocked(); }
|
||||
|
||||
bool ModbusClientHub::tx_buffer_empty() {
|
||||
// "Empty" for ready_for_immediate_send(): no one-shot is queued ahead of the caller. Entries in
|
||||
@@ -219,10 +199,9 @@ void ModbusServerHub::parse_modbus_frames() {
|
||||
this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true);
|
||||
}
|
||||
|
||||
// Scans forward from min_length to find a frame boundary by CRC match for unknown-length function codes.
|
||||
// Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE.
|
||||
uint16_t Modbus::find_frame_end_by_crc_(uint16_t min_length) const {
|
||||
// Unknown-length functions (user-defined codes, unimplemented management codes, unassigned values)
|
||||
// could be any length - we have to rely on the CRC to determine completeness.
|
||||
// If a CRC match is never found, the buffer will eventually overflow and be cleared.
|
||||
const uint8_t *raw = &this->rx_buffer_[0];
|
||||
const size_t size = this->rx_buffer_.size();
|
||||
const auto max_len = static_cast<uint16_t>(std::min(size, size_t(MAX_FRAME_SIZE)));
|
||||
@@ -531,8 +510,7 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<
|
||||
return;
|
||||
}
|
||||
// A broadcast is never answered, so a rejecting device has no other feedback channel: report the
|
||||
// per-device outcome at V, and warn if the write reached nobody at all.
|
||||
bool accepted = false;
|
||||
// per-device outcome at V.
|
||||
for (auto *device : this->devices_) {
|
||||
// Same handlers as an addressed write - a device cannot tell a broadcast apart, and does not need
|
||||
// to: the hub owns the difference, which is only that no reply is ever sent.
|
||||
@@ -542,24 +520,6 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<
|
||||
if (device_status.has_value()) {
|
||||
ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(),
|
||||
static_cast<uint8_t>(device_status.value()));
|
||||
} else {
|
||||
accepted = true;
|
||||
}
|
||||
}
|
||||
if (!accepted && !this->devices_.empty()) {
|
||||
const uint16_t entity_count = coils ? coil_count : static_cast<uint16_t>(registers.size());
|
||||
const LogString *const entity_name = coils ? LOG_STR("coils") : LOG_STR("registers");
|
||||
// Warn at most once per interval, then drop to VERBOSE: on a shared bus a broadcast aimed at other nodes
|
||||
// repeats forever, so warning per frame would flood the log.
|
||||
const uint32_t now = millis();
|
||||
if (this->last_unaccepted_broadcast_warn_ == 0 ||
|
||||
now - this->last_unaccepted_broadcast_warn_ > UNACCEPTED_BROADCAST_WARN_INTERVAL_MS) {
|
||||
this->last_unaccepted_broadcast_warn_ = now;
|
||||
ESP_LOGW(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count,
|
||||
LOG_STR_ARG(entity_name), start_address);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count,
|
||||
LOG_STR_ARG(entity_name), start_address);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -783,8 +743,6 @@ bool Modbus::send_frame_(const ModbusFrame &frame) {
|
||||
delay(tx_delay_remaining);
|
||||
}
|
||||
|
||||
// The delay above can span several ms; a byte arriving in that window blocks transmission after the
|
||||
// caller's gate already passed. Don't collide with the incoming frame - leave the entry to retry.
|
||||
if (this->tx_blocked()) {
|
||||
return false;
|
||||
}
|
||||
@@ -831,7 +789,7 @@ void ModbusClientHub::send_next_frame_() {
|
||||
// reports the transmission, and the entry then retires with no terminal callback instead of
|
||||
// occupying the waiting slot until the send-wait timeout expires. The turnaround delay already
|
||||
// spaces the next frame; the following sweep erases the entry.
|
||||
ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected (fire-and-forget)");
|
||||
ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected");
|
||||
cmd->complete_broadcast();
|
||||
this->sweep_needed_ = true;
|
||||
return;
|
||||
@@ -983,6 +941,8 @@ bool ModbusDeviceCommand::timed_out() {
|
||||
this->decrement_pending(); // resolve this request (WAITING-origin, so pending >= 1)
|
||||
if (this->device == nullptr)
|
||||
return false; // resolved, no one to tell
|
||||
// A cleared frame that timed out still honors a retry: the clear is address-scoped (any device may
|
||||
// call it) while the retry is the owning device's call via on_no_response - the bus obeys the owner.
|
||||
if (this->device->on_no_response(this->frame.pdu()))
|
||||
this->increment_pending(); // granted retry = re-request (capped)
|
||||
return true;
|
||||
@@ -1054,18 +1014,14 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
|
||||
ESP_LOGE(TAG, "Frame too large, refused: %" PRIu8 ":%zu bytes", address, pdu.size());
|
||||
return false;
|
||||
}
|
||||
// classify() drives both the broadcast guard and the continuous check below; compute it once.
|
||||
const CommandPriority priority = ModbusDeviceCommand::classify(pdu[0]);
|
||||
|
||||
// A broadcast (address 0) is never answered (Modbus 4.1), so it is only meaningful for a command that
|
||||
// changes state. Refuse a broadcast that expects a reply - anything but a write or a custom/vendor code -
|
||||
// as it could never deliver a result, so the caller learns via the false return (and on_not_sent).
|
||||
// 0x17 (read/write multiple) is a knowing inclusion: classify() treats it as a write, so its write half
|
||||
// lands on every server and its unanswerable read half is simply discarded. An exception-flagged custom
|
||||
// code (0x80 bit set) is refused: is_function_code_custom() masks that bit away, so exclude it explicitly
|
||||
// here to match classify()'s exception-first handling of the write side.
|
||||
if (address == BROADCAST_ADDRESS && priority != CommandPriority::WRITE &&
|
||||
(!helpers::is_function_code_custom(pdu[0]) || helpers::is_function_code_exception(pdu[0]))) {
|
||||
if (helpers::is_function_code_exception(pdu[0])) {
|
||||
ESP_LOGW(TAG, "Exception PDU refused for address %" PRIu8 ": function code 0x%X has the exception bit set", address,
|
||||
pdu[0]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (address == BROADCAST_ADDRESS && !helpers::is_function_code_broadcastable(pdu[0])) {
|
||||
ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]);
|
||||
return false;
|
||||
}
|
||||
@@ -1073,7 +1029,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
|
||||
// Normalize the caller's options in place (the param is a by-value copy) so everything stored or
|
||||
// merged below carries effective options, never the raw request.
|
||||
// continuous is ignored for every mutating code (re-writing a value forever is never intended).
|
||||
if (options.continuous && priority == CommandPriority::WRITE) {
|
||||
if (options.continuous && helpers::is_function_code_write(pdu[0])) {
|
||||
ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
|
||||
options.continuous = false;
|
||||
}
|
||||
@@ -1089,9 +1045,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
|
||||
continue;
|
||||
if (device == nullptr) {
|
||||
// A dropped read is routine (DEBUG); a dropped write/custom warns (unobservable without a device).
|
||||
const bool requeueable =
|
||||
!helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read_only(pdu[0]);
|
||||
if (requeueable) {
|
||||
if (helpers::is_function_code_read_only(pdu[0])) {
|
||||
ESP_LOGD(TAG, "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped", address, pdu[0]);
|
||||
} else {
|
||||
ESP_LOGW(TAG,
|
||||
@@ -1364,7 +1318,6 @@ void ModbusClientDevice::dispatch_response_(std::span<const uint8_t> request_pdu
|
||||
}
|
||||
}
|
||||
|
||||
// Default on_custom_response handler to warn when responses unexpectedly trigger on_custom_response
|
||||
void ModbusClientDevice::on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
|
||||
ResponseStatus status) {
|
||||
// The dispatcher never calls this with an empty request, but this is a public virtual - stay safe.
|
||||
|
||||
@@ -16,26 +16,21 @@
|
||||
|
||||
namespace esphome::modbus {
|
||||
|
||||
// Tx queue backstop. Duplicate frames dedup into one entry, so reads can never approach this in a
|
||||
// sane config - it exists to stop a runaway generator of distinct frames (e.g. a loop writing a
|
||||
// changing value) from growing the heap unboundedly. The deque grows on demand; this reserves nothing.
|
||||
// Worst case the cap permits: 128 distinct max-size frames = ~32 kB of spilled frame data plus
|
||||
// ~3 kB of deque node storage (typical 8-byte frames stay inline; large PDUs spill to one
|
||||
// allocation each) - pathological configs only, but the numbers matter when tuning for ESP8266.
|
||||
// Tx queue backstop: duplicates dedup into one entry, so only a runaway generator of distinct frames
|
||||
// (e.g. a loop writing a changing value) could grow the heap unboundedly.
|
||||
static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 128;
|
||||
static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5;
|
||||
|
||||
// Typical frames -- reads and single-register/coil writes -- are exactly 8 bytes
|
||||
// (address + 5-byte PDU + 2-byte CRC) and fit inline with no heap allocation.
|
||||
// (address + 5-byte PDU + 2-byte CRC).
|
||||
static constexpr uint16_t MODBUS_FRAME_INLINE_SIZE = 8;
|
||||
|
||||
struct ModbusFrame {
|
||||
// Frame held in a small-buffer-optimized buffer. Typical frames fit inline; only larger
|
||||
// multi-register or custom frames spill to a single heap allocation. This keeps the common,
|
||||
// high-frequency tx traffic off the heap entirely, avoiding per-frame alloc/free churn.
|
||||
// The buffer tracks its own length, so no separate size field is needed.
|
||||
SmallInlineBuffer<MODBUS_FRAME_INLINE_SIZE> data; // Modbus RTU max is 256 bytes
|
||||
// Small-buffer-optimized: typical frames fit inline, keeping high-frequency tx traffic off the
|
||||
// heap; only large multi-register or custom frames spill to a single heap allocation.
|
||||
SmallInlineBuffer<MODBUS_FRAME_INLINE_SIZE> data;
|
||||
|
||||
// A frame is [address][PDU...][CRC lo][CRC hi]. These are the only places that need to know that layout
|
||||
ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len) {
|
||||
uint8_t *buf = this->data.init(pdu_len + 3);
|
||||
buf[0] = address;
|
||||
@@ -46,12 +41,9 @@ 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.
|
||||
/// A PDU is [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)
|
||||
std::span<const uint8_t> pdu() const { return std::span<const uint8_t>(this->data.data() + 1, this->size() - 3u); }
|
||||
};
|
||||
|
||||
@@ -73,15 +65,9 @@ class Modbus : public uart::UARTDevice, public Component {
|
||||
virtual int32_t tx_delay_remaining();
|
||||
virtual void parse_modbus_frames() = 0;
|
||||
bool parse_modbus_server_frame_();
|
||||
// pdu is the whole PDU (function code + payload, no address/CRC); pdu[0] is the (standard or custom) function code.
|
||||
virtual void process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) = 0;
|
||||
void clear_rx_buffer_(const LogString *reason, bool warn = false, size_t bytes_to_clear = 0);
|
||||
// Transmit a frame. Callers gate on tx_blocked() first, but the pre-send delay can span several ms,
|
||||
// so this re-checks after the delay and returns false without transmitting if a byte arrived in that
|
||||
// window (the caller then leaves its entry to retry). Returns true once the frame has been transmitted.
|
||||
bool send_frame_(const ModbusFrame &frame);
|
||||
// Scans forward from min_length to find a frame boundary by CRC match for custom function codes.
|
||||
// Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE.
|
||||
uint16_t find_frame_end_by_crc_(uint16_t min_length) const;
|
||||
|
||||
uint32_t last_modbus_byte_{0};
|
||||
@@ -99,8 +85,7 @@ class Modbus : public uart::UARTDevice, public Component {
|
||||
class ModbusClientDevice;
|
||||
class ModbusServerDevice;
|
||||
|
||||
// Transmit ordering, highest first: writes before one-shot reads before continuous polls. Derived
|
||||
// at selection time, never caller-chosen or stored.
|
||||
// Transmit ordering, highest first: writes before one-shot reads before continuous polls.
|
||||
enum class CommandPriority : uint8_t { CONTINUOUS = 0, READ, WRITE };
|
||||
|
||||
// Per-entry lifecycle state. Waiting states (see waiting_state()) hold the bus; the sweep delivers owed
|
||||
@@ -112,20 +97,15 @@ enum class FrameState : uint8_t {
|
||||
RECEIVED_EXCEPTION,
|
||||
TIMED_OUT, // on_no_response delivered at the send-wait timeout; awaiting reschedule/erase
|
||||
INTERRUPTED, // unexpected frame arrived; ignores this transaction, waits out the timeout
|
||||
WAITING_RETIRED, // cleared while WAITING: a late response is still delivered as its usual terminal
|
||||
INTERRUPTED_RETIRED, // cleared while INTERRUPTED: still distrusts late frames, ends in on_no_response
|
||||
RETIRED, // cleared, off the wire
|
||||
WAITING_RETIRED, // retired while WAITING: a late response is still delivered as its usual terminal
|
||||
INTERRUPTED_RETIRED, // retired while INTERRUPTED: still distrusts late frames, ends in on_no_response
|
||||
RETIRED, // retired, off the wire
|
||||
};
|
||||
|
||||
// Per-command send options. Append-only; pass via designated initializers ({.continuous = true}).
|
||||
// The queue entry stores this struct whole, so a new field arrives at the queue with no plumbing -
|
||||
// but it arrives inert. Every new field must define three rules before it does anything:
|
||||
// 1. normalization in queue_pdu() (is it valid for this function code? e.g. continuous is
|
||||
// stripped for mutating codes),
|
||||
// 2. a merge rule for when a duplicate send absorbs into a live entry (continuous
|
||||
// upgrades/downgrades via make_continuous(); a new field needs its own answer),
|
||||
// 3. teardown: retire() resets the whole struct; silent_retire() leaves it, relying on the sweep
|
||||
// to erase the entry.
|
||||
// A new field reaches the queue with no plumbing but arrives inert until it defines three rules:
|
||||
// normalization in queue_pdu(), a merge rule for duplicate absorption, and teardown in
|
||||
// retire()/silent_retire().
|
||||
struct CommandOptions {
|
||||
// A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes.
|
||||
bool continuous{false};
|
||||
@@ -135,17 +115,13 @@ struct ModbusDeviceCommand {
|
||||
ModbusClientDevice *device;
|
||||
ModbusFrame frame;
|
||||
// Place-in-line stamp (hub's free-running counter); selection takes the oldest for round-robin
|
||||
// fairness within a class. Meant to wrap. Declared ahead of the byte fields so the tail packs
|
||||
// densely and a growing CommandOptions eats trailing padding before enlarging the struct.
|
||||
// fairness within a class. Meant to wrap.
|
||||
uint16_t seq{0};
|
||||
FrameState state{FrameState::READY};
|
||||
// Accepted requests this entry stands for, capped at max_pending(); drains one terminal each.
|
||||
// A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure.
|
||||
uint8_t pending{1};
|
||||
// The entry's LIVE effective options, not a record of the caller's request: queue_pdu() normalizes
|
||||
// before storing, duplicate absorption mutates continuous via make_continuous(), and retire() resets
|
||||
// the struct (silent_retire() leaves it, relying on the sweep to erase the entry). See the
|
||||
// CommandOptions comment for the rules a new field must define.
|
||||
// The entry's LIVE effective options, not a record of the caller's request
|
||||
CommandOptions options;
|
||||
|
||||
// Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE) and pre-normalized options;
|
||||
@@ -154,28 +130,22 @@ struct ModbusDeviceCommand {
|
||||
CommandOptions options = {}, uint16_t seq = 0)
|
||||
: device(device), frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())), seq(seq), options(options) {}
|
||||
|
||||
// Transmit ordering class, derived (never stored): a continuous poll ranks below every one-shot.
|
||||
CommandPriority priority() const {
|
||||
return this->options.continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]);
|
||||
}
|
||||
// Wire-derived class: mutating codes rank WRITE; exception-flagged codes are excluded.
|
||||
static CommandPriority classify(uint8_t function_code) {
|
||||
if (helpers::is_function_code_exception(function_code))
|
||||
return CommandPriority::READ;
|
||||
if (helpers::is_function_code_write(function_code)) {
|
||||
if (this->options.continuous)
|
||||
return CommandPriority::CONTINUOUS;
|
||||
if (helpers::is_function_code_write(this->frame.pdu()[0])) {
|
||||
return CommandPriority::WRITE;
|
||||
}
|
||||
return CommandPriority::READ;
|
||||
}
|
||||
|
||||
// Requests this entry can serve: a standard read twice (run plus one re-run), everything else once.
|
||||
// Requests this entry can serve
|
||||
uint8_t max_pending() const {
|
||||
const uint8_t fc = this->frame.pdu()[0];
|
||||
const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read_only(fc);
|
||||
return (requeueable && !this->options.continuous) ? 2 : 1;
|
||||
return (helpers::is_function_code_read_only(fc) && !this->options.continuous) ? 2 : 1;
|
||||
}
|
||||
// Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for
|
||||
// a response keeps its state as a reply-ignoring shell that resolves silently; any other goes RETIRED.
|
||||
// Device-scoped clear: detach with no callback. An entry still waiting for a response keeps its state as a
|
||||
// reply-ignoring shell that resolves silently; any other goes RETIRED.
|
||||
void silent_retire() {
|
||||
if (!this->waiting_state())
|
||||
this->state = FrameState::RETIRED;
|
||||
@@ -183,28 +153,18 @@ struct ModbusDeviceCommand {
|
||||
this->device = nullptr;
|
||||
}
|
||||
// Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already
|
||||
// fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with NO terminal
|
||||
// callback and the sweep erases it. Unlike response()/error()/timed_out(), it delivers nothing.
|
||||
// A broadcast only carries a write or a custom code (reads are refused at queue_pdu()), and every such
|
||||
// code caps pending at 1, so pending is always 1 here - clear it.
|
||||
// fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with no terminal callback.
|
||||
void complete_broadcast() {
|
||||
this->state = FrameState::RETIRED;
|
||||
this->pending = 0;
|
||||
}
|
||||
// Re-ready for another transmission, restamped to the tail of its class (hub passes next_seq_++).
|
||||
// Re-ready for another transmission, restamped to the tail of its class
|
||||
void requeue(uint16_t seq) {
|
||||
this->state = FrameState::READY;
|
||||
this->seq = seq;
|
||||
}
|
||||
// Re-task a frame that lives on: upgrade a one-shot to a continuous poll, or downgrade a poll back to
|
||||
// a one-shot. Either way the entry keeps running and owes a request, so this is not a plain setter -
|
||||
// to tear an entry down instead, use retire()/silent_retire(), which leave pending as the count owed.
|
||||
// On: the entry becomes a continuous poll, superseding any absorbed requests (pending resets to the
|
||||
// single subscription). Off: a one-shot duplicate has cancelled the poll, but the entry must still run
|
||||
// once to serve that request - so restore one first. While the flag is still set max_pending() is 1,
|
||||
// so the restore lifts a terminated poll (pending 0, after an error/timeout) back to 1 and is a no-op
|
||||
// on a live poll already at 1; the flag drops afterwards, when a read's cap can widen to 2 without
|
||||
// retroactively inflating that no-op.
|
||||
// a one-shot.
|
||||
void make_continuous(bool continuous) {
|
||||
if (continuous) {
|
||||
this->options.continuous = true;
|
||||
@@ -214,13 +174,9 @@ struct ModbusDeviceCommand {
|
||||
this->options.continuous = false;
|
||||
}
|
||||
}
|
||||
// Address-scoped clear: keep pending and device so the sweep delivers one on_not_sent() per un-run
|
||||
// Address-scoped clear: keep pending and device so the sweep delivers one on_not_sent() per un-delivered
|
||||
// request. An entry still waiting for a response keeps its in-flight request (whose usual terminal is
|
||||
// still coming) and drains only its duplicates: WAITING -> WAITING_RETIRED, and INTERRUPTED ->
|
||||
// INTERRUPTED_RETIRED which keeps distrusting late frames (they were already interrupted). Any other
|
||||
// state -> RETIRED, draining everything. A cleared frame that then times out still honors a retry:
|
||||
// the clear is address-scoped (any device may call it) while the retry is the owning device's call
|
||||
// via on_no_response - the bus obeys the owner.
|
||||
// still coming) and drains only its duplicates.
|
||||
void retire() {
|
||||
if (this->state == FrameState::WAITING) {
|
||||
this->state = FrameState::WAITING_RETIRED;
|
||||
@@ -229,10 +185,10 @@ struct ModbusDeviceCommand {
|
||||
} else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED
|
||||
this->state = FrameState::RETIRED;
|
||||
}
|
||||
this->options = {}; // reset every option so a future field is torn down without editing here
|
||||
this->options = {}; // reset every option
|
||||
}
|
||||
|
||||
// True while the entry is still waiting for a response; the erase pass exempts these even at pending 0.
|
||||
// True while the entry is still waiting for a response
|
||||
bool waiting_state() const {
|
||||
return this->state == FrameState::WAITING || this->state == FrameState::INTERRUPTED ||
|
||||
this->state == FrameState::WAITING_RETIRED || this->state == FrameState::INTERRUPTED_RETIRED;
|
||||
@@ -245,7 +201,7 @@ struct ModbusDeviceCommand {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Add one request, honouring the cap; false = already at cap (absorb a duplicate, restore a retry).
|
||||
|
||||
bool increment_pending() {
|
||||
if (this->pending < this->max_pending()) {
|
||||
this->pending++;
|
||||
@@ -255,7 +211,7 @@ struct ModbusDeviceCommand {
|
||||
}
|
||||
|
||||
// Terminal/lifecycle methods: each owns its transition, callback, and pending accounting and
|
||||
// returns whether a callback ran. Out-of-line: ModbusClientDevice is incomplete here.
|
||||
// returns whether a callback ran.
|
||||
bool sent();
|
||||
bool response(std::span<const uint8_t> response_pdu);
|
||||
bool error(ExceptionCode exception_code);
|
||||
@@ -264,9 +220,6 @@ struct ModbusDeviceCommand {
|
||||
bool notify_retired();
|
||||
|
||||
/// True if this command carries the same wire frame (address + PDU) as the given one.
|
||||
/// Cancellation matches the exact frame, not the action instance: a continuous poll whose
|
||||
/// start_address (or other field) is templated produces one poll per distinct frame, and a later
|
||||
/// cancel built from different argument values will not reach the polls it does not byte-match.
|
||||
bool same_frame(uint8_t address, std::span<const uint8_t> pdu) const {
|
||||
const auto own_pdu = this->frame.pdu();
|
||||
return own_pdu.size() == pdu.size() && this->frame.address() == address &&
|
||||
@@ -291,17 +244,13 @@ class ModbusClientHub : public Modbus {
|
||||
payload_len),
|
||||
device);
|
||||
};
|
||||
/// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and
|
||||
/// goes out later from loop(), so a true return means accepted into the machine (it will resolve in
|
||||
/// exactly one terminal callback - except a broadcast (address 0), which is never answered and so gets
|
||||
/// only on_sent()), NOT that anything reached the wire - that is on_sent(). False means
|
||||
/// it never entered the machine at all (empty or oversize PDU, full queue, anonymous or over-cap
|
||||
/// duplicate) and no callback of any kind will follow; the false return is the whole story.
|
||||
/// Queue a request. True = accepted: it resolves in exactly one terminal callback (a broadcast,
|
||||
/// address 0, gets only on_sent()). False = refused, and no callback of any kind follows.
|
||||
/// Neither means anything reached the wire - on_sent() reports that.
|
||||
bool queue_pdu(uint8_t address, std::span<const uint8_t> pdu, ModbusClientDevice *device = nullptr,
|
||||
CommandOptions options = {});
|
||||
// Remove before 2027.2.0. Deliberately the signature 2026.7.4 shipped - void, and no CommandOptions:
|
||||
// the bool return and the options argument arrived after that release, so nothing external can be
|
||||
// relying on them under this name. Callers who want the queued/refused answer move to queue_pdu().
|
||||
// Remove before 2027.2.0. Deliberately the void, no-options signature 2026.7.4 shipped: nothing
|
||||
// external can rely on the later additions under this name.
|
||||
ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it "
|
||||
"reports whether the request was accepted. Removed in 2027.2.0",
|
||||
"2026.8.0")
|
||||
@@ -310,9 +259,10 @@ class ModbusClientHub : public Modbus {
|
||||
}
|
||||
ESPDEPRECATED("Use queue_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);
|
||||
// Clear an address's commands; each un-run request resolves via on_not_sent(), but a frame on the
|
||||
// wire still runs to its usual terminal. clear_tx_queue_for_device() instead discards silently.
|
||||
// Clear all commands matching the given address; each unsent request resolves via on_not_sent(), but a
|
||||
// frame on the wire still runs to its usual terminal.
|
||||
void clear_tx_queue_for_address(uint8_t address);
|
||||
// Clear all commands for a given device; no callbacks are delivered.
|
||||
void clear_tx_queue_for_device(ModbusClientDevice *device);
|
||||
|
||||
protected:
|
||||
@@ -322,8 +272,7 @@ class ModbusClientHub : public Modbus {
|
||||
void send_next_frame_();
|
||||
// Deliver owed callbacks from a quiescent hub and apply lifecycle bookkeeping; see FrameState.
|
||||
void sweep_();
|
||||
// The selection function: best READY entry (WRITE class first, then one-shot reads, then the
|
||||
// least-recently-served continuous; FIFO by seq within each group), or nullptr.
|
||||
// The selection function: best READY entry (ordered by priority; FIFO by seq within each group), or nullptr.
|
||||
ModbusDeviceCommand *select_next_ready_();
|
||||
// Locate the single entry waiting for a response (WAITING/INTERRUPTED/WAITING_RETIRED/INTERRUPTED_RETIRED).
|
||||
ModbusDeviceCommand *find_waiting_();
|
||||
@@ -349,13 +298,10 @@ class ModbusClientHub : public Modbus {
|
||||
// Transaction status: std::nullopt on success, otherwise a Modbus exception code
|
||||
using ResponseStatus = std::optional<ExceptionCode>;
|
||||
|
||||
/// True when a transaction carried no exception. The optional holds the exception, so has_value() means
|
||||
/// the request FAILED - the inverse of how "status" usually reads. Prefer this at the call site; the
|
||||
/// bare !status.has_value() has already been mistaken for a failure check more than once. Where the code
|
||||
/// is going to unwrap the exception anyway, status.has_value() followed by status.value() stays clearer.
|
||||
/// True when a transaction carried no exception.
|
||||
inline bool succeeded(ResponseStatus status) { return !status.has_value(); }
|
||||
|
||||
// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol
|
||||
// Register values exchanged with server handlers, in address 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.
|
||||
using RegisterValues = StaticVector<uint16_t, MAX_NUM_OF_REGISTERS_TO_READ>;
|
||||
@@ -373,59 +319,46 @@ class ModbusServerHub : public Modbus {
|
||||
void process_modbus_client_frame_(uint8_t address, uint8_t function_code, std::span<const uint8_t> data);
|
||||
// Dispatches a broadcast (address 0) write to every registered device; broadcasts are never answered.
|
||||
void process_broadcast_frame_(uint8_t function_code, std::span<const uint8_t> data);
|
||||
// Parses a WRITE_SINGLE_REGISTER / WRITE_MULTIPLE_REGISTERS PDU into start_address and the host-order register
|
||||
// values, validating the register count and address range. Returns std::nullopt on success, otherwise the Modbus
|
||||
// exception code describing the failure. Shared by unicast writes (which reply with the exception) and broadcast
|
||||
// writes (which silently drop invalid frames).
|
||||
// Parses a WRITE_SINGLE_REGISTER / WRITE_MULTIPLE_REGISTERS PDU into start_address and the address order register
|
||||
// values, validating the register count and address range. Shared by unicast and broadcast writes.
|
||||
ResponseStatus parse_write_single_(std::span<const uint8_t> data, uint16_t &start_address, RegisterValues ®isters);
|
||||
ResponseStatus parse_write_multiple_(std::span<const uint8_t> data, uint16_t &start_address,
|
||||
RegisterValues ®isters);
|
||||
// Appends the big-endian register values in values to registers, in host byte order.
|
||||
// Assembles host-order registers from the big-endian bytes in values and appends them to registers.
|
||||
void assemble_registers_(std::span<const uint8_t> values, RegisterValues ®isters);
|
||||
ModbusServerDevice *find_device_(uint8_t address);
|
||||
// Returns std::nullopt if [start_address, start_address + count) fits in the 16-bit address space,
|
||||
// otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required - a broadcast
|
||||
// write is never answered, so the check cannot send it itself. Shared by the register and
|
||||
// coil/discrete-input handlers, which all address the same 16-bit space.
|
||||
// Returns std::nullopt if [start_address, start_address + count) fits in a 16-bit address space, otherwise
|
||||
// ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required. Shared by the
|
||||
// register/coil/discrete-input handlers, which all use a 16-bit address space.
|
||||
ResponseStatus check_address_range_(uint16_t start_address, uint16_t count);
|
||||
|
||||
// Parses a read request PDU (start address(2) + quantity(2)), shared by the register and
|
||||
// coil/discrete-input reads so the two cannot drift apart. max_entities is the protocol ceiling for the
|
||||
// function code; entity_name only labels the rejection log.
|
||||
// Parses read request data. max_entities is the protocol ceiling for the function code; entity_name labels
|
||||
// the rejection log.
|
||||
ResponseStatus parse_read_request_(std::span<const uint8_t> data, uint16_t max_entities, const LogString *entity_name,
|
||||
uint16_t &start_address, uint16_t &count);
|
||||
|
||||
// Parses a single-coil write PDU (FC 0x05), which carries a 2-byte on/off value rather than packed
|
||||
// bytes. The caller packs value into a byte it owns to build the PackedBits view the handlers take.
|
||||
// Parses single-coil write data
|
||||
ResponseStatus parse_write_single_coil_(std::span<const uint8_t> data, uint16_t &start_address, bool &value);
|
||||
|
||||
// Parses a multiple-coil write PDU (FC 0x0F) into a packed-bit view pointing straight into the receive
|
||||
// buffer, so the coil values are never copied. Both coil parsers are shared by the addressed and
|
||||
// broadcast paths so the two validate identically.
|
||||
// Parses write-multiple-coil data into a packed-bit view pointing straight into the receive buffer, so the
|
||||
// coil values are never copied.
|
||||
ResponseStatus parse_write_multiple_coils_(std::span<const uint8_t> data, uint16_t &start_address, uint16_t &count,
|
||||
std::span<const uint8_t> &packed_bytes);
|
||||
|
||||
// Builds the body of a register read response (byte count followed by the big-endian register values) into
|
||||
// response_buffer. Shared by every function code that answers with register values, so the read reply stays
|
||||
// identical across them. Returns false once an exception has been sent: the one the handler reported via
|
||||
// status, or SERVICE_DEVICE_FAILURE if it returned the wrong number of registers, the count exceeds the
|
||||
// protocol read limit, or the body does not fit.
|
||||
// Builds the body of a register read response into response_buffer. Returns false once an exception has
|
||||
// been sent: the one the handler reported via status, or SERVICE_DEVICE_FAILURE if it returned the wrong
|
||||
// number of registers, the count exceeds the protocol read limit, or the body does not fit.
|
||||
bool build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status,
|
||||
uint16_t number_of_registers, const RegisterValues ®isters,
|
||||
std::span<uint8_t> response_buffer, uint16_t &response_len);
|
||||
void send_raw_(const uint8_t *payload, uint16_t len);
|
||||
// Sends and logs the exception reply when status holds one; returns true if the request was rejected.
|
||||
// Every parse and handler rejection funnels through here, so the reply and its log cannot drift apart.
|
||||
bool rejected_(uint8_t address, uint8_t function_code, ResponseStatus status);
|
||||
void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code);
|
||||
void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len);
|
||||
uint8_t expecting_peer_response_{0};
|
||||
std::vector<ModbusServerDevice *> devices_;
|
||||
|
||||
// Stamp of the last "broadcast reached no device" warning, 0 until the first one is logged. Rate limiting
|
||||
// on time rather than on address keeps the log bounded no matter how many addresses a shared bus carries.
|
||||
uint32_t last_unaccepted_broadcast_warn_{0};
|
||||
|
||||
// Holds the raw payload of a single reply deferred for sending when tx was blocked at send time.
|
||||
// Only one server reply can be waiting at once, so a single fixed buffer avoids heap allocation.
|
||||
std::array<uint8_t, MAX_RAW_SIZE> deferred_payload_;
|
||||
@@ -555,10 +488,7 @@ class ModbusClientDevice {
|
||||
helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len),
|
||||
this);
|
||||
}
|
||||
/// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will
|
||||
/// follow (except a broadcast (address 0), which is never answered and so gets only on_sent()),
|
||||
/// false = refused at the door and nothing further happens. Neither means the frame is on the wire;
|
||||
/// on_sent() reports that.
|
||||
/// See ModbusClientHub::queue_pdu() for the return contract.
|
||||
bool queue_pdu(std::span<const uint8_t> pdu, CommandOptions options = {}) {
|
||||
return this->parent_->queue_pdu(this->address_, pdu, this, options);
|
||||
}
|
||||
@@ -573,11 +503,8 @@ class ModbusClientDevice {
|
||||
return; // too short to contain a PDU; refused at the door like any invalid send
|
||||
this->parent_->queue_pdu(payload[0], std::span<const uint8_t>(payload).subspan(1), this);
|
||||
}
|
||||
// The typed request builders below all queue through queue_pdu(), so they share its contract: true
|
||||
// means the request is queued and will resolve in exactly one terminal callback (except a broadcast
|
||||
// (address 0), which is never answered and so gets only on_sent()), false means it was refused outright
|
||||
// with no callback. Neither says the frame has been transmitted - on_sent() does.
|
||||
// Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which
|
||||
// The typed request builders below all queue through queue_pdu() and share its return contract.
|
||||
// Reads use the table-appropriate function code; an unreadable entity type maps to INVALID, which
|
||||
// create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return.
|
||||
bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities,
|
||||
CommandOptions options = {}) {
|
||||
@@ -619,11 +546,9 @@ class ModbusClientDevice {
|
||||
bool write_multiple_coils(uint16_t start_address, PackedBits bits) {
|
||||
return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits));
|
||||
}
|
||||
/// FC 0x17: the read-back is delivered through on_read_holding_registers() (the response carries only the
|
||||
/// read registers, the same wire shape as a holding-register read). A device exception - typically a
|
||||
/// rejected write half - arrives at that same on_read_holding_registers() with the error in its status,
|
||||
/// exactly as success does, so a subclass overriding that one callback handles both outcomes and never
|
||||
/// needs to also override on_error().
|
||||
/// FC 0x17: the read-back is delivered through on_read_holding_registers(), and a device exception
|
||||
/// (typically a rejected write half) arrives there too via its status - one callback handles both
|
||||
/// outcomes with no on_error() override needed.
|
||||
bool read_write_multiple_registers(uint16_t read_start_address, uint16_t read_count, uint16_t write_start_address,
|
||||
std::span<const uint16_t> write_values) {
|
||||
return this->queue_pdu(helpers::create_read_write_multiple_registers_pdu(read_start_address, read_count,
|
||||
@@ -644,12 +569,9 @@ class ModbusClientDevice {
|
||||
bool custom_response_warned_{false}; // first unhandled custom response warns; repeats log at VERBOSE
|
||||
};
|
||||
|
||||
// Compatibility shim for external components written against the pre-2026.8 API, which subclassed
|
||||
// ModbusDevice and overrode on_modbus_data()/on_modbus_error(). The name is free (nothing in-tree
|
||||
// uses it), so instead of a plain alias it adapts the new span-based hooks back to the old
|
||||
// signatures: on_modbus_data() receives the response payload as an owning vector (the heap copy
|
||||
// exists only on this deprecated path) and on_modbus_error() the function code and exception code.
|
||||
// Remove before 2027.2.0 (window restarted when the plain alias became a behavior shim in 2026.8.0)
|
||||
// Compatibility shim adapting the span-based hooks back to the pre-2026.8 on_modbus_data()/
|
||||
// on_modbus_error() signatures (the owning-vector heap copy exists only on this deprecated path).
|
||||
// Remove before 2027.2.0 (window restarted when the plain alias became a behavior shim in 2026.8.0).
|
||||
class ESPDEPRECATED("Subclass ModbusClientDevice and override on_response()/on_error() instead. Removed in 2027.2.0",
|
||||
"2026.8.0") ModbusDevice : public ModbusClientDevice {
|
||||
public:
|
||||
|
||||
@@ -47,7 +47,6 @@ enum class FunctionCode : uint8_t {
|
||||
using ModbusFunctionCode ESPDEPRECATED("Use modbus::FunctionCode instead. Removed in 2027.2.0",
|
||||
"2026.8.0") = FunctionCode;
|
||||
|
||||
/*Allow direct comparison operators between FunctionCode and uint8_t*/
|
||||
inline bool operator==(FunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) == rhs; }
|
||||
inline bool operator==(uint8_t lhs, FunctionCode rhs) { return lhs == static_cast<uint8_t>(rhs); }
|
||||
inline bool operator!=(FunctionCode lhs, uint8_t rhs) { return !(static_cast<uint8_t>(lhs) == rhs); }
|
||||
|
||||
@@ -30,9 +30,11 @@ uint16_t server_pdu_length(const uint8_t *frame, size_t size) {
|
||||
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) + data
|
||||
// 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:
|
||||
@@ -60,6 +62,9 @@ uint16_t server_pdu_length(const uint8_t *frame, size_t size) {
|
||||
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:
|
||||
@@ -381,8 +386,6 @@ ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint
|
||||
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_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",
|
||||
@@ -445,9 +448,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address,
|
||||
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.
|
||||
// (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;
|
||||
|
||||
@@ -60,14 +60,11 @@ inline bool is_function_code_custom(uint8_t function_code) {
|
||||
/// in step with those switches). Deliberately wider than is_function_code_custom(): the user-defined
|
||||
/// ranges are unknown to the parser too, but so are the assigned-but-unimplemented codes
|
||||
/// (READ_EXCEPTION_STATUS, DIAGNOSTICS, GET_COMM_EVENT_*, REPORT_SERVER_ID) and every unassigned value.
|
||||
/// The 0x80 exception flag is masked off first, so a frame with it set classifies by its base code -
|
||||
/// even though a spec exception reply has a known 2-byte PDU. That is deliberate, matching what
|
||||
/// is_function_code_custom() has always done: some vendors use codes with the 0x80 bit set as ordinary
|
||||
/// codes with longer payloads, so the response parser CRC-scans these rather than assuming the spec
|
||||
/// length. For an intact spec exception the scan matches at its first candidate, so only a corrupt one
|
||||
/// pays (recovery by timeout instead of an immediate CRC failure).
|
||||
/// Exception-flagged codes (0x80 set) are always the 2-byte spec exception shape, so never unknown.
|
||||
inline bool is_function_code_unknown_length(uint8_t function_code) {
|
||||
switch (static_cast<FunctionCode>(function_code & FUNCTION_CODE_MASK)) {
|
||||
if (is_function_code_exception(function_code))
|
||||
return false;
|
||||
switch (static_cast<FunctionCode>(function_code)) {
|
||||
case FunctionCode::READ_COILS:
|
||||
case FunctionCode::READ_DISCRETE_INPUTS:
|
||||
case FunctionCode::READ_HOLDING_REGISTERS:
|
||||
@@ -87,6 +84,17 @@ inline bool is_function_code_unknown_length(uint8_t function_code) {
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the underlying function code (exception bit masked off) may be broadcast (address 0).
|
||||
/// Refused: the reads (including read-write), plus every other code whose response length the parser
|
||||
/// knows (file record, FIFO). Allowed: the writes, and any code the parser does not know, since the
|
||||
/// hub cannot tell one of those apart from a vendor write.
|
||||
inline bool is_function_code_broadcastable(uint8_t function_code) {
|
||||
uint8_t masked_function_code = function_code & FUNCTION_CODE_MASK;
|
||||
if (is_function_code_read(masked_function_code))
|
||||
return false;
|
||||
return is_function_code_write(masked_function_code) || is_function_code_unknown_length(masked_function_code);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -205,7 +213,7 @@ enum class SensorValueType : uint8_t {
|
||||
S_DWORD = 0x4, // 2 Registers signed
|
||||
BIT = 0x5,
|
||||
U_DWORD_R = 0x6, // 2 Registers unsigned
|
||||
S_DWORD_R = 0x7, // 2 Registers unsigned
|
||||
S_DWORD_R = 0x7, // 2 Registers signed
|
||||
U_QWORD = 0x8,
|
||||
S_QWORD = 0x9,
|
||||
U_QWORD_R = 0xA,
|
||||
@@ -280,7 +288,7 @@ inline uint8_t c_to_hex(char c) { return (c >= 'A') ? (c >= 'a') ? (c - 'a' + 10
|
||||
* byte_from_hex_str("1122", 1) returns uint_8 value 0x22 == 34
|
||||
* byte_from_hex_str("1122", 0) returns 0x11
|
||||
* @param value string containing hex encoding
|
||||
* @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in
|
||||
* @param pos offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in
|
||||
* the hex string is byte_pos * 2
|
||||
* @return byte value
|
||||
*/
|
||||
@@ -292,8 +300,7 @@ inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) {
|
||||
|
||||
/** Get a word from a hex string
|
||||
* @param value string containing hex encoding
|
||||
* @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in
|
||||
* the hex string is byte_pos * 2
|
||||
* @param pos offset in bytes (see byte_from_hex_str)
|
||||
* @return word value
|
||||
*/
|
||||
inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) {
|
||||
@@ -302,8 +309,7 @@ inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) {
|
||||
|
||||
/** Get a dword from a hex string
|
||||
* @param value string containing hex encoding
|
||||
* @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in
|
||||
* the hex string is byte_pos * 2
|
||||
* @param pos offset in bytes (see byte_from_hex_str)
|
||||
* @return dword value
|
||||
*/
|
||||
inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) {
|
||||
@@ -312,8 +318,7 @@ inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) {
|
||||
|
||||
/** Get a qword from a hex string
|
||||
* @param value string containing hex encoding
|
||||
* @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in
|
||||
* the hex string is byte_pos * 2
|
||||
* @param pos offset in bytes (see byte_from_hex_str)
|
||||
* @return qword value
|
||||
*/
|
||||
inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) {
|
||||
@@ -328,9 +333,9 @@ template<typename T> T get_data(const std::vector<uint8_t> &data, size_t buffer_
|
||||
* Responses for coil are packed into bytes .
|
||||
* coil 3 is bit 3 of the first response byte
|
||||
* coil 9 is bit 2 of the second response byte
|
||||
* @param coil number of the cil
|
||||
* @param bit index of the bit to extract
|
||||
* @param data modbus response buffer (uint8_t)
|
||||
* @return content of coil register
|
||||
* @return value of the requested bit
|
||||
*/
|
||||
inline bool bit_from_packed(int bit, std::span<const uint8_t> data) {
|
||||
auto data_byte = bit / 8;
|
||||
|
||||
@@ -775,7 +775,7 @@ TEST(ModbusClientHubBroadcast, DeliversNoTerminalToTypedDevice) {
|
||||
|
||||
// A broadcast is only meaningful for a command that changes state; a broadcast READ could never be
|
||||
// answered, so the hub refuses it at the door (false return, no entry queued) rather than silently
|
||||
// retiring it. Writes, 0x17, and custom codes still go through (covered above).
|
||||
// retiring it. Writes and custom/unknown codes still go through (covered in the neighboring tests).
|
||||
TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
@@ -814,9 +814,8 @@ TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) {
|
||||
EXPECT_EQ(hub.entries(), 0u); // the entry is gone
|
||||
}
|
||||
|
||||
// An exception-flagged custom code (0x80 bit set) is not a real request: is_function_code_custom() masks
|
||||
// the bit away and would accept it, but the broadcast guard excludes it, matching classify()'s handling
|
||||
// of an exception-flagged write.
|
||||
// An exception-flagged code (0x80 bit set) is never a valid request - that bit is response-only - so
|
||||
// queue_pdu refuses it up front, before the broadcast guard, whatever its base code.
|
||||
TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
@@ -833,6 +832,50 @@ TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) {
|
||||
EXPECT_EQ(device.sent_count_, 0); // never transmitted
|
||||
}
|
||||
|
||||
// FC23 (read/write multiple) has a read half that expects a reply, so the Modbus spec does not allow it
|
||||
// as a broadcast. is_function_code_read() covers it, so the broadcast guard refuses it despite its write
|
||||
// half.
|
||||
TEST(ModbusClientHubBroadcast, RefusesReadWriteMultipleBroadcast) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
|
||||
|
||||
// fc, read start+qty, write start+qty, byte count, one data word.
|
||||
const uint8_t read_write_multiple[] = {0x17, 0x00, 0x00, 0x00, 0x01, 0x00, 0x10, 0x00, 0x01, 0x02, 0xBE, 0xEF};
|
||||
EXPECT_FALSE(device.queue_pdu(read_write_multiple)); // its read half could never be answered
|
||||
EXPECT_EQ(hub.entries(), 0u);
|
||||
}
|
||||
|
||||
// FC 0x18 (read FIFO queue) is not a "read" by is_function_code_read(), but the hub has an explicit
|
||||
// response-length rule for it - it demonstrably expects a reply, so it cannot broadcast.
|
||||
TEST(ModbusClientHubBroadcast, RefusesKnownLengthNonWriteBroadcast) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
|
||||
|
||||
const uint8_t read_fifo[] = {0x18, 0x00, 0x10}; // fc, FIFO pointer address
|
||||
EXPECT_FALSE(device.queue_pdu(read_fifo));
|
||||
EXPECT_EQ(hub.entries(), 0u);
|
||||
}
|
||||
|
||||
// A code that is neither a read nor exception-flagged (here 0x63, unassigned) is fire-and-forget on a
|
||||
// broadcast: the hub can't know it isn't a vendor write, so it is accepted and delivered to all devices.
|
||||
TEST(ModbusClientHubBroadcast, AcceptsNonReadUnknownBroadcast) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
|
||||
|
||||
const uint8_t unknown[] = {0x63, 0x00, 0x01};
|
||||
EXPECT_TRUE(device.queue_pdu(unknown)); // not a read, so not refused
|
||||
EXPECT_EQ(hub.entries(), 1u);
|
||||
}
|
||||
|
||||
namespace {
|
||||
// tx_blocked() clear for send_next_frame_'s gate, then blocked for send_frame_'s post-delay re-check.
|
||||
class RejectPostDelayHub : public NoResponseProbeHub {
|
||||
@@ -1882,30 +1925,20 @@ TEST(ModbusClientHubPriority, ResendFromOnResponseAbsorbsIntoCompletingCommand)
|
||||
EXPECT_FALSE(hub.queued(0).options.continuous); // the one-shot re-send downgraded the poll
|
||||
}
|
||||
|
||||
// An exception-flagged function code is never silently re-sendable, even though the read check
|
||||
// masks the exception bit: its duplicate takes the drop path like any other non-read.
|
||||
TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) {
|
||||
// The exception bit marks a response, so a request carrying it is refused outright.
|
||||
TEST(ModbusClientHubPriority, ExceptionFlaggedPduRefused) {
|
||||
NoResponseProbeHub hub;
|
||||
SentCountingDevice device(&hub, 0x02);
|
||||
|
||||
const uint8_t weird[] = {0x83, 0x01, 0x00, 0x00, 0x02}; // read-shaped but exception-flagged
|
||||
EXPECT_TRUE(device.queue_pdu(weird));
|
||||
EXPECT_FALSE(device.queue_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused
|
||||
// The 0x80 exception flag is a response-only bit; a request must never set it. queue_pdu refuses an
|
||||
// exception-flagged PDU up front - nothing is queued - whether its base code reads (0x83 = 0x03 | 0x80)
|
||||
// or writes (0x86 = 0x06 | 0x80).
|
||||
const uint8_t read_shaped[] = {0x83, 0x01, 0x00, 0x00, 0x02};
|
||||
const uint8_t write_shaped[] = {0x86, 0x00, 0x10, 0xBE, 0xEF};
|
||||
EXPECT_FALSE(device.queue_pdu(read_shaped));
|
||||
EXPECT_FALSE(device.queue_pdu(write_shaped));
|
||||
hub.sweep_for_test();
|
||||
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_EQ(hub.queued(0).pending, 1u);
|
||||
EXPECT_EQ(device.not_sent_count_, 0);
|
||||
|
||||
// The write-shaped twin (0x86 masks to WRITE_SINGLE_REGISTER) must not take WRITE-class
|
||||
// ordering either: exception-flagged codes are excluded from the mutates classification.
|
||||
const uint8_t weird_write[] = {0x86, 0x00, 0x10, 0xBE, 0xEF};
|
||||
device.queue_pdu(weird_write);
|
||||
ASSERT_EQ(hub.queued_frames(), 2u);
|
||||
EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); // not WRITE
|
||||
const ModbusDeviceCommand *next = hub.next_ready();
|
||||
ASSERT_NE(next, nullptr);
|
||||
EXPECT_EQ(next->frame.pdu()[0], 0x83); // FIFO by age: it did not jump the older entry
|
||||
EXPECT_EQ(hub.queued_frames(), 0u);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -63,6 +63,12 @@ TEST(ModbusClientFrameLength, TooShortReturnsMinimum) {
|
||||
EXPECT_EQ(client_frame_length(frame, 1), MIN_FRAME_SIZE);
|
||||
}
|
||||
|
||||
TEST(ModbusClientFrameLength, ExceptionFlaggedIsTheExceptionShape) {
|
||||
// Sized at 2 so an exception-flagged request fails its CRC at once instead of being scanned for.
|
||||
const uint8_t exception_request[] = {0x83, 0x02};
|
||||
EXPECT_EQ(client_pdu_length(exception_request, sizeof(exception_request)), 2);
|
||||
}
|
||||
|
||||
TEST(ModbusClientFrameLength, ReadAndWriteSingleAreFixed) {
|
||||
// basic_register request fixture is a read-holding request -> 8 bytes
|
||||
const uint8_t read[] = {0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A};
|
||||
|
||||
@@ -54,7 +54,8 @@ class TestServerHub : public ModbusServerHub {
|
||||
|
||||
// The frame-length parsers have explicit cases for exactly these 13 codes; every other value - the
|
||||
// assigned-but-unimplemented management codes, both user-defined ranges, and all unassigned codes -
|
||||
// must classify as unknown length. The exception flag masks off first.
|
||||
// must classify as unknown length. Exception replies are always the 2-byte spec shape, so every
|
||||
// 0x80-set code is known length.
|
||||
TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
|
||||
for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x14, 0x15, 0x16, 0x17, 0x18}) {
|
||||
EXPECT_FALSE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc);
|
||||
@@ -62,11 +63,13 @@ TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
|
||||
for (uint8_t fc : {0x07, 0x08, 0x0B, 0x0C, 0x11, 0x2A, 0x41, 0x48, 0x49, 0x64, 0x6E, 0x00, 0x7F}) {
|
||||
EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc);
|
||||
}
|
||||
// Exception replies classify by their base code.
|
||||
// Every exception-flagged code is known length (the 2-byte spec exception shape), whatever its base.
|
||||
EXPECT_FALSE(helpers::is_function_code_unknown_length(0x83));
|
||||
EXPECT_TRUE(helpers::is_function_code_unknown_length(0x87));
|
||||
// Strictly wider than the user-defined ranges: every custom code is unknown-length, but not vice versa.
|
||||
for (int fc = 0; fc <= 0xFF; fc++) {
|
||||
EXPECT_FALSE(helpers::is_function_code_unknown_length(0x87));
|
||||
EXPECT_FALSE(helpers::is_function_code_unknown_length(0xC9));
|
||||
// Strictly wider than the user-defined ranges below 0x80: every non-exception custom code is
|
||||
// unknown-length, but not vice versa.
|
||||
for (int fc = 0; fc <= 0x7F; fc++) {
|
||||
if (helpers::is_function_code_custom(fc))
|
||||
EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << fc;
|
||||
}
|
||||
@@ -75,10 +78,10 @@ TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
|
||||
// Derived contract check: the helper must say "unknown" exactly when both length parsers fall
|
||||
// through to default. With a zero-filled max-size PDU every explicit case returns at least 2
|
||||
// (file records bottom out at 2, FIFO at 3) and only default returns MIN_PDU_SIZE, so comparing
|
||||
// against MIN_PDU_SIZE detects a case added to either switch without updating the helper. The
|
||||
// loop stops at 0x7F: above it the helper masks the exception flag off while client_pdu_length()
|
||||
// switches on the unmasked byte and server_pdu_length() early-returns the exception length.
|
||||
for (int fc = 0; fc <= 0x7F; fc++) {
|
||||
// against MIN_PDU_SIZE detects a case added to either switch without updating the helper. Both
|
||||
// parsers early-return the 2-byte exception shape above 0x7F, which the helper's own exception
|
||||
// early-return mirrors, so the whole byte range is covered.
|
||||
for (int fc = 0; fc <= 0xFF; fc++) {
|
||||
const uint8_t pdu[MAX_PDU_SIZE] = {static_cast<uint8_t>(fc)}; // zero header fields
|
||||
EXPECT_EQ(helpers::is_function_code_unknown_length(fc),
|
||||
helpers::client_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE)
|
||||
@@ -89,6 +92,17 @@ TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcastable = writes plus unknown codes (possible vendor writes); everything known to expect a
|
||||
// reply is not. Classifies the underlying code: the exception bit masks off first (0x85 as 0x05).
|
||||
TEST(ModbusUnknownFunction, BroadcastableClassification) {
|
||||
for (uint8_t fc : {0x05, 0x06, 0x0F, 0x10, 0x16, 0x49, 0x63, 0x6E, 0x85, 0xC9}) {
|
||||
EXPECT_TRUE(helpers::is_function_code_broadcastable(fc)) << "fc 0x" << std::hex << int(fc);
|
||||
}
|
||||
for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17, 0x18, 0x83, 0x97}) {
|
||||
EXPECT_FALSE(helpers::is_function_code_broadcastable(fc)) << "fc 0x" << std::hex << int(fc);
|
||||
}
|
||||
}
|
||||
|
||||
// A response with a function code outside the user-defined ranges (0x49) has no length case in
|
||||
// server_pdu_length(), so the parser must find the frame end by CRC scan - the same way it already
|
||||
// handles user-defined codes. Frame: address + FC 0x49 + 3 data bytes + CRC = 7 bytes. Without the
|
||||
|
||||
Reference in New Issue
Block a user