Files
esphome/tests/components/modbus/modbus_client_hub_test.cpp
T

2152 lines
90 KiB
C++

#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include <span>
#include <vector>
#include "common.h"
#include "esphome/components/modbus/modbus.h"
#include "esphome/core/hal.h"
namespace esphome::modbus::testing {
namespace {
// Exposes the frame state machine so tests can drive it without a UART (force_send_next(),
// timeout_waiting(), sweep_for_test() stand in for the loop() transmit/watchdog/sweep steps).
class NoResponseProbeHub : public ModbusClientHub {
public:
// The old "queue" view: entries awaiting transmission, in STORAGE order (selection order is
// what the engine transmits by; use next_ready() for that).
size_t queued_frames() const {
size_t count = 0;
for (const auto &cmd : this->tx_buffer_) {
if (cmd.state == FrameState::READY)
count++;
}
return count;
}
// A never-null placeholder to return when a lookup fails, so a tripped EXPECT/ADD_FAILURE reports
// the assertion instead of dereferencing null / an empty deque and segfaulting the whole suite.
static const ModbusDeviceCommand &dummy_command() {
static const uint8_t DUMMY_PDU[1] = {0x00};
static ModbusDeviceCommand cmd(nullptr, 0, std::span<const uint8_t>(DUMMY_PDU, 1));
return cmd;
}
const ModbusDeviceCommand &queued(size_t i) const {
for (const auto &cmd : this->tx_buffer_) {
if (cmd.state == FrameState::READY && i-- == 0)
return cmd;
}
ADD_FAILURE() << "no READY entry at that index";
return dummy_command();
}
size_t entries() const { return this->tx_buffer_.size(); }
const ModbusDeviceCommand *next_ready() { return this->select_next_ready_(); }
bool waiting() const { return this->waiting_for_response_; }
const ModbusDeviceCommand &waiting_command() {
ModbusDeviceCommand *cmd = this->find_waiting_();
EXPECT_NE(cmd, nullptr);
return cmd != nullptr ? *cmd : dummy_command();
}
void sweep_for_test() { this->sweep_(); }
void send_next_for_test() {
this->send_next_frame_();
this->sweep_(); // a transmit failure's on_not_sent() is delivered by the loop's sweep
}
void force_send_next() {
ModbusDeviceCommand *cmd = this->select_next_ready_();
ASSERT_NE(cmd, nullptr) << "no READY entry to send";
cmd->state = FrameState::WAITING;
this->waiting_for_response_ = true;
}
// Drives the real response/interruption branches, followed by the loop's sweep.
void receive_frame_for_test(uint8_t address, std::span<const uint8_t> pdu) {
this->process_modbus_server_frame(address, pdu);
this->sweep_();
}
void timeout_waiting() {
this->sweep_(); // deliver anything already owed (e.g. an interruption's on_no_response)
this->expire_waiting_();
this->sweep_();
}
};
// A device with a scripted answer to on_no_response().
class RetryingDevice : public ModbusClientDevice {
public:
RetryingDevice(ModbusClientHub *hub, uint8_t address, bool retry) : ModbusClientDevice(hub, address), retry_(retry) {}
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_count_++;
return this->retry_;
}
int no_response_count_{0};
protected:
bool retry_{false};
};
// A device that clears its own queued traffic from inside the no-response callback, then asks for a retry.
class ClearingRetryDevice : public ModbusClientDevice {
public:
ClearingRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_count_++;
this->clear_tx_queue_for_device(); // detaches this device from the waiting slot mid-callback
return true; // and still requests a retry
}
int no_response_count_{0};
};
constexpr uint8_t READ_PDU[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // read 2 holding registers at 0x100
StaticVector<uint8_t, MAX_PDU_SIZE> read_pdu() {
StaticVector<uint8_t, MAX_PDU_SIZE> pdu;
pdu.assign(READ_PDU, READ_PDU + sizeof(READ_PDU));
return pdu;
}
} // namespace
// A device that requests a retry gets the frame the hub was holding re-queued on its behalf,
// byte-identical and still routed to the same device.
TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/true);
device.queue_pdu(read_pdu());
ASSERT_EQ(hub.queued_frames(), 1u);
hub.force_send_next();
ASSERT_EQ(hub.queued_frames(), 0u);
ASSERT_TRUE(hub.waiting());
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_FALSE(hub.waiting());
ASSERT_EQ(hub.queued_frames(), 1u);
const ModbusDeviceCommand &requeued = hub.queued(0);
EXPECT_EQ(requeued.device, &device);
// address + PDU + CRC
ASSERT_EQ(requeued.frame.size(), sizeof(READ_PDU) + 3);
EXPECT_EQ(requeued.frame.address(), 0x02);
ASSERT_EQ(requeued.frame.pdu().size(), sizeof(READ_PDU));
EXPECT_EQ(0, memcmp(requeued.frame.pdu().data(), READ_PDU, sizeof(READ_PDU)));
}
// A device that declines the retry has the frame dropped.
TEST(ModbusClientHubNoResponse, NoRetryDropsWaitingFrame) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/false);
device.queue_pdu(read_pdu());
hub.force_send_next();
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.queued_frames(), 0u);
}
// After the device is detached from the waiting frame (e.g. clear_tx_queue_for_device on
// destruction), a timeout must not deliver a callback or re-queue anything.
TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) {
NoResponseProbeHub hub;
{
RetryingDevice device(&hub, 0x02, /*retry=*/true);
device.queue_pdu(read_pdu());
hub.force_send_next();
// device destructor clears its queue entries, including the waiting frame's device pointer
}
ASSERT_TRUE(hub.waiting());
EXPECT_EQ(hub.waiting_command().device, nullptr);
hub.timeout_waiting();
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.queued_frames(), 0u);
}
// An unexpected frame interrupts the transaction: the entry becomes an INTERRUPTED shell that
// ignores this transaction and blocks tx until the send-wait timeout, where it gets its single
// on_no_response() - a granted retry is requeued there, like any other timeout.
TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/true);
device.queue_pdu(read_pdu());
hub.force_send_next();
// A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch.
const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x07, stray_pdu);
hub.sweep_for_test();
EXPECT_EQ(device.no_response_count_, 0); // not notified early: it waits out the timeout
ASSERT_TRUE(hub.waiting()); // and keeps blocking the bus
EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED);
EXPECT_EQ(hub.waiting_command().pending, 1u);
// The send-wait timeout delivers on_no_response and requeues the granted retry.
hub.timeout_waiting();
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(device.no_response_count_, 1);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).device, &device);
}
// The declined-retry interrupted shell blocks until the send-wait timeout, then gets its single
// on_no_response() there and retires with nothing left to send.
TEST(ModbusClientHubNoResponse, InterruptedShellDeclinedRetryRetiresOnRelease) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/false);
device.queue_pdu(read_pdu());
hub.force_send_next();
const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x07, stray_pdu); // wrong address: interrupts the transaction
hub.sweep_for_test();
EXPECT_EQ(device.no_response_count_, 0); // not notified early
ASSERT_TRUE(hub.waiting()); // the shell still blocks the wire
EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED);
EXPECT_EQ(hub.waiting_command().pending, 1u);
hub.timeout_waiting(); // on_no_response (declined), then the shell retires
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.queued_frames(), 0u);
EXPECT_EQ(device.no_response_count_, 1);
}
// A callback that detaches the device (clear_tx_queue_for_device()) wins over its own retry request:
// no orphaned frame with a null device is re-queued.
TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) {
NoResponseProbeHub hub;
ClearingRetryDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
hub.force_send_next();
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_EQ(hub.queued_frames(), 0u); // the retry was not re-queued for a detached device
EXPECT_FALSE(hub.waiting());
}
// Writes jump ahead of queued reads; reads keep FIFO order among themselves.
TEST(ModbusClientHubPriority, WritesSendBeforeQueuedReads) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/false);
const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02};
const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02};
const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF};
device.queue_pdu(read_a);
device.queue_pdu(read_b);
device.queue_pdu(write_pdu);
ASSERT_EQ(hub.queued_frames(), 3u);
hub.force_send_next();
EXPECT_EQ(hub.waiting_command().frame.pdu()[0], 0x06); // the write transmits first
hub.timeout_waiting();
hub.force_send_next();
EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x01); // reads follow in FIFO order
hub.timeout_waiting();
hub.force_send_next();
EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x02);
}
// Re-requesting a queued frame is absorbed into the existing entry instead of queueing a duplicate.
TEST(ModbusClientHubPriority, DuplicateQueuedFrameAbsorbedNotDuplicated) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/false);
device.queue_pdu(read_pdu());
device.queue_pdu(read_pdu());
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).pending, 2u); // one entry standing for two accepted requests
}
// Re-requesting the frame currently waiting is absorbed into the waiting entry; after a
// no-response timeout the absorbed request still gets its run even though the device declines a
// retry, and a second timeout does not run it again.
TEST(ModbusClientHubPriority, InFlightDuplicateRunsOnceMore) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/false);
device.queue_pdu(read_pdu());
hub.force_send_next();
device.queue_pdu(read_pdu()); // duplicate of the waiting frame
EXPECT_EQ(hub.queued_frames(), 0u); // not queued twice
EXPECT_EQ(hub.waiting_command().pending, 2u);
hub.timeout_waiting();
ASSERT_EQ(hub.queued_frames(), 1u); // the timeout resolved one request; the absorbed one runs
EXPECT_EQ(hub.queued(0).pending, 1u);
hub.force_send_next();
hub.timeout_waiting();
EXPECT_EQ(hub.queued_frames(), 0u); // the last request resolved; nothing left to run
}
// An entry with an absorbed extra request that times out while the device asks to retry: the
// retry is not a resolution, so BOTH requests remain pending rather than one being dropped -
// which would leave that caller without a resolution.
TEST(ModbusClientHubPriority, AbsorbedRequestSurvivesDeviceRetry) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/true);
device.queue_pdu(read_pdu());
hub.force_send_next();
device.queue_pdu(read_pdu()); // duplicate of the waiting frame -> absorbed
ASSERT_EQ(hub.waiting_command().pending, 2u);
hub.timeout_waiting(); // no response; the device requests a retry
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).pending, 2u); // preserved: the retry resolved nothing
}
// A continuous read re-queues itself (at the lowest priority) after each successful response,
// but not after an exception response.
TEST(ModbusClientHubPriority, ContinuousReadRequeuesOnSuccessOnly) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/false);
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
hub.force_send_next();
// A matching successful response cycles the continuous entry back to READY.
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
// An exception response ends the poll.
hub.force_send_next();
const uint8_t exception_response[] = {0x83, 0x02};
hub.receive_frame_for_test(0x02, exception_response);
EXPECT_EQ(hub.queued_frames(), 0u);
}
// A continuous read that gets no response and is retried stays continuous: an explicit retry of a
// continuous poll is assumed to still want continuous polling (the entry stays continuous).
TEST(ModbusClientHubPriority, RetriedContinuousReadStaysContinuous) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/true);
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
hub.force_send_next();
hub.timeout_waiting(); // no response -> device requests retry
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous); // the retried poll stays continuous
}
// A one-shot duplicate downgrades a continuous poll to a one-shot (the mirror of a continuous
// duplicate upgrading a one-shot): the entry runs one more cycle to serve the request, then stops.
TEST(ModbusClientHubPriority, DuplicateSendDowngradesContinuous) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/false);
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
device.read_holding_registers(0x100, 2); // one-shot duplicate downgrades the poll
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_FALSE(hub.queued(0).continuous);
EXPECT_EQ(hub.queued(0).pending, 1u);
// It runs one more cycle to serve the request, then stops - not re-queued as a poll.
hub.force_send_next();
EXPECT_FALSE(hub.waiting_command().continuous);
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
EXPECT_EQ(hub.queued_frames(), 0u);
EXPECT_EQ(hub.entries(), 0u);
}
namespace {
// Re-sends its frame once as a one-shot from inside on_error(), to exercise the downgrade branch
// when the poll it duplicates has already reached a terminal (pending drained to 0).
class ResendOnErrorDevice : public ModbusClientDevice {
public:
ResendOnErrorDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_error(std::span<const uint8_t> request_pdu, ExceptionCode exception_code) override {
this->error_count_++;
if (this->resend_) {
this->resend_ = false;
this->read_holding_registers(0x100, 2); // one-shot re-send from inside the failure callback
}
}
int error_count_{0};
bool resend_{true};
};
} // namespace
// A one-shot re-send issued from inside a continuous poll's failure callback must still run. The
// poll's exception terminal has already drained pending to 0, so the re-send absorbs into that entry
// via the downgrade branch - which must restore the debt, or the sweep erases the entry with the
// request never sent and no callback delivered.
TEST(ModbusClientHubPriority, DowngradeAfterTerminalKeepsRequestAlive) {
NoResponseProbeHub hub;
ResendOnErrorDevice device(&hub, 0x02);
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
hub.force_send_next();
const uint8_t exception_response[] = {0x83, 0x02};
hub.receive_frame_for_test(0x02, exception_response); // exception ends the poll; on_error re-sends
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
EXPECT_FALSE(hub.queued(0).continuous); // downgraded to a one-shot
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
// And it runs to its own terminal - a good response this time - then the entry is gone.
hub.force_send_next();
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
EXPECT_EQ(hub.queued_frames(), 0u);
EXPECT_EQ(hub.entries(), 0u);
}
// Requesting continuous polling for a frame that is already queued as a one-shot turns that entry
// into the continuous poll instead of leaving a promotion that never polls.
TEST(ModbusClientHubPriority, ContinuousRequestUpgradesQueuedDuplicate) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/false);
device.read_holding_registers(0x100, 2);
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_FALSE(hub.queued(0).continuous);
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
// And it behaves as a poll from here: success cycles it back to READY.
hub.force_send_next();
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
}
// The transmit order is one key with three levels: writes, then one-shot reads, then continuous
// polls - a poll only gets the bus when nothing else wants it.
TEST(ModbusClientHubPriority, WritesThenOneShotReadsThenContinuousPolls) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/false);
// Queued oldest-first in the opposite order to the one they must transmit in, so age cannot be
// what produces the expected sequence.
device.read_holding_registers(0x100, 2, {.continuous = true});
const uint8_t one_shot[] = {0x03, 0x02, 0x00, 0x00, 0x01};
const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF};
device.queue_pdu(one_shot);
device.queue_pdu(write_pdu);
ASSERT_EQ(hub.queued_frames(), 3u);
EXPECT_EQ(hub.queued(0).priority(), CommandPriority::CONTINUOUS);
EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ);
EXPECT_EQ(hub.queued(2).priority(), CommandPriority::WRITE);
hub.force_send_next();
EXPECT_EQ(hub.waiting_command().frame.pdu()[0], 0x06); // the write goes first
hub.timeout_waiting();
hub.force_send_next();
EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x02); // then the one-shot read
hub.timeout_waiting();
hub.force_send_next();
EXPECT_TRUE(hub.waiting_command().continuous); // and the poll takes what is left
}
// continuous is ignored for writes: the frame still sends at WRITE priority, once.
TEST(ModbusClientHubPriority, ContinuousIgnoredForWrites) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/false);
const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF};
device.queue_pdu(write_pdu, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE);
EXPECT_FALSE(hub.queued(0).continuous);
}
// A queued continuous poll does not count against immediate-send readiness: it ranks below every
// one-shot, so a new one-shot goes out ahead of it. A queued one-shot does count.
TEST(ModbusClientHubPriority, ContinuousPollDoesNotBlockImmediateSend) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/false);
EXPECT_TRUE(hub.tx_buffer_empty()); // nothing queued
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.tx_buffer_empty()); // a READY continuous poll still leaves room to send now
device.read_holding_registers(0x200, 2); // a one-shot does count
EXPECT_FALSE(hub.tx_buffer_empty());
}
// A device whose sent/not-sent callbacks are counted.
namespace {
class SentCountingDevice : public ModbusClientDevice {
public:
SentCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_sent(std::span<const uint8_t> request_pdu) override {
this->sent_count_++;
this->last_sent_pdu_.assign(request_pdu.begin(), request_pdu.end());
}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
this->last_not_sent_pdu_.assign(request_pdu.begin(), request_pdu.end());
}
int sent_count_{0};
int not_sent_count_{0};
std::vector<uint8_t> last_sent_pdu_;
std::vector<uint8_t> last_not_sent_pdu_;
};
} // namespace
// A write is never requeueable, so its entry can serve exactly one request: a duplicate of a
// queued write is refused at the door rather than earning the write an extra transmission.
TEST(ModbusClientHubPriority, DuplicateQueuedWriteRefused) {
NoResponseProbeHub hub;
SentCountingDevice device(&hub, 0x02);
const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF};
EXPECT_TRUE(device.queue_pdu(write_pdu));
EXPECT_FALSE(device.queue_pdu(write_pdu)); // duplicate write: refused
hub.sweep_for_test();
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE);
EXPECT_EQ(hub.queued(0).pending, 1u); // a write's cap
EXPECT_EQ(device.not_sent_count_, 0); // refusals are returned, never delivered
}
// Requeueability is an allow-list of the standard reads: a custom function code's idempotency is
// unknown, so its duplicate is refused like a write's instead of earning a silent re-send.
TEST(ModbusClientHubPriority, DuplicateCustomFunctionCodeRefused) {
NoResponseProbeHub hub;
SentCountingDevice device(&hub, 0x02);
const uint8_t custom_pdu[] = {0x41, 0x01, 0x02}; // user-defined function code
EXPECT_TRUE(device.queue_pdu(custom_pdu));
EXPECT_FALSE(device.queue_pdu(custom_pdu)); // duplicate custom command: refused
hub.sweep_for_test();
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).pending, 1u); // the non-requeueable cap of one run
EXPECT_EQ(device.not_sent_count_, 0);
}
// An anonymous duplicate (no device - the YAML-lambda path) is always dropped, never promoted:
// with no callback there is no lifecycle to absorb into and no owner to route a re-run to.
TEST(ModbusClientHubPriority, AnonymousDuplicateDroppedNotPromoted) {
NoResponseProbeHub hub;
const uint8_t read[] = {0x03, 0x01, 0x00, 0x00, 0x02};
hub.queue_pdu(0x02, read);
hub.queue_pdu(0x02, read); // anonymous duplicate: dropped
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).pending, 1u); // never absorbed for a null owner
}
// A retried entry is re-stamped to the queue tail: reads that arrived while it was waiting get
// their turn before the retry, so a frame that keeps timing out cannot starve the rest of the bus.
TEST(ModbusClientHubPriority, RetriedReadGoesBehindFreshReads) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/true);
device.queue_pdu(read_pdu());
hub.force_send_next(); // the frame that will time out and retry
device.queue_pdu(read_pdu()); // waiting duplicate: absorbed into the waiting entry
const uint8_t fresh_a[] = {0x03, 0x00, 0x10, 0x00, 0x01};
const uint8_t fresh_b[] = {0x03, 0x00, 0x20, 0x00, 0x01};
device.queue_pdu(fresh_a);
device.queue_pdu(fresh_b);
ASSERT_EQ(hub.queued_frames(), 2u);
hub.timeout_waiting(); // device retries; the entry returns to READY behind the fresh reads
ASSERT_EQ(hub.queued_frames(), 3u);
const ModbusDeviceCommand *next = hub.next_ready();
ASSERT_NE(next, nullptr);
EXPECT_EQ(next->frame.pdu()[2], 0x10); // fresh reads keep FIFO order ahead of the retry
hub.force_send_next();
hub.timeout_waiting();
hub.force_send_next();
EXPECT_EQ(hub.waiting_command().frame.pdu()[2], 0x20);
hub.timeout_waiting();
hub.force_send_next(); // the retry gets its turn last, both requests still on the entry
EXPECT_TRUE(std::equal(hub.waiting_command().frame.pdu().begin(), hub.waiting_command().frame.pdu().end(), READ_PDU));
EXPECT_EQ(hub.waiting_command().pending, 2u);
}
// An absorbed duplicate does not move the entry back in line: seq belongs to the entry, and only
// re-entering the line (retry, resolved request, continuous cycle) re-stamps it.
TEST(ModbusClientHubPriority, AbsorbedDuplicateKeepsPlaceInLine) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/false);
const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01};
const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01};
device.queue_pdu(read_a);
device.queue_pdu(read_b);
device.queue_pdu(read_a); // duplicate of the older entry: absorbed, place unchanged
ASSERT_EQ(hub.queued_frames(), 2u);
const ModbusDeviceCommand *next = hub.next_ready();
ASSERT_NE(next, nullptr);
EXPECT_EQ(next->frame.pdu()[2], 0x10); // read_a still transmits first
EXPECT_EQ(next->pending, 2u);
}
// A write that is retried after a no-response keeps the WRITE class, so it stays ahead of reads,
// and a later duplicate still resolves against it instead of queueing twice.
TEST(ModbusClientHubPriority, RetriedWriteKeepsWritePriorityAndStaysNonRequeueable) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/true);
const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF};
device.queue_pdu(write_pdu);
hub.force_send_next();
hub.timeout_waiting(); // no response -> device requests retry -> back to READY
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); // retry preserves the WRITE class
device.queue_pdu(write_pdu); // duplicate of the retried write
ASSERT_EQ(hub.queued_frames(), 1u); // still not queued twice...
EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE);
hub.sweep_for_test();
EXPECT_EQ(hub.queued(0).pending, 1u); // ...the duplicate was refused at the door (write cap is 1)
}
namespace {
// A hub that is never free to transmit.
class AlwaysBlockedHub : public NoResponseProbeHub {
public:
bool tx_blocked() override { return true; }
};
} // namespace
// Transmitting cannot fail, so a hub that is busy simply does not transmit: the frame keeps its
// place in the queue and goes out on a later loop, with no callback and no lifecycle change. (The
// caller owns the tx_blocked() check; send_frame_() has no gate of its own to refuse at.)
TEST(ModbusClientHubSent, BlockedHubDefersInsteadOfFailing) {
AlwaysBlockedHub hub;
SentCountingDevice device(&hub, 0x02);
EXPECT_TRUE(device.queue_pdu(read_pdu()));
hub.send_next_for_test();
EXPECT_EQ(device.sent_count_, 0);
EXPECT_EQ(device.not_sent_count_, 0); // nothing failed - it has not been attempted
ASSERT_EQ(hub.queued_frames(), 1u); // still queued, still owed exactly one terminal
EXPECT_EQ(hub.queued(0).pending, 1u);
EXPECT_FALSE(hub.waiting());
}
// on_sent() fires when the frame goes onto the wire, not when it is queued.
TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup(); // frame timing derives from the baud rate
SentCountingDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
EXPECT_EQ(device.sent_count_, 0); // queued only - nothing sent yet
hub.send_next_for_test();
EXPECT_EQ(device.sent_count_, 1);
EXPECT_EQ(device.not_sent_count_, 0);
// The callback identifies which command transmitted: it carries the request PDU.
EXPECT_EQ(device.last_sent_pdu_, (std::vector<uint8_t>(READ_PDU, READ_PDU + sizeof(READ_PDU))));
EXPECT_TRUE(hub.waiting());
}
namespace {
// Records on_sent / on_response / on_no_response so a broadcast's fire-and-forget completion
// (on_sent, and no terminal) can be asserted.
class BroadcastProbeDevice : public ModbusClientDevice {
public:
BroadcastProbeDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_sent(std::span<const uint8_t> request_pdu) override { this->sent_count_++; }
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->response_count_++;
this->last_response_size_ = response_pdu.size();
}
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_count_++;
return false;
}
int sent_count_{0};
int response_count_{0};
int no_response_count_{0};
size_t last_response_size_{0};
};
} // namespace
// A broadcast (address 0) is never answered (Modbus 4.1), so the client treats it as fire-and-forget:
// on_sent fires as the frame goes out, NO terminal (on_response/on_error/on_no_response) is delivered,
// the hub is left NOT waiting - no timeout is burned - and the sweep erases the entry.
TEST(ModbusClientHubBroadcast, CompletesAtTransmissionWithoutWaiting) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; // write single register 0x0010 = 0x0001
ASSERT_TRUE(device.queue_pdu(write));
EXPECT_EQ(hub.queued_frames(), 1u);
hub.send_next_for_test(); // transmit + sweep
EXPECT_EQ(device.sent_count_, 1); // the frame went on the wire
EXPECT_EQ(device.response_count_, 0); // fire-and-forget: no terminal callback
EXPECT_EQ(device.no_response_count_, 0); // and it never waited for a reply
EXPECT_FALSE(hub.waiting()); // no waiting slot occupied
EXPECT_EQ(hub.queued_frames(), 0u); // and the entry is gone
EXPECT_EQ(hub.entries(), 0u);
}
namespace {
// Keeps the DEFAULT on_response() (so the base typed dispatcher runs) and records the typed write
// callback and the catch-all, to prove a broadcast reaches neither - only on_sent.
class BroadcastTypedProbeDevice : public ModbusClientDevice {
public:
BroadcastTypedProbeDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_sent(std::span<const uint8_t> request_pdu) override { this->sent_count_++; }
void on_write_single_register(uint16_t address, uint16_t value, ResponseStatus status) override {
this->write_single_count_++;
}
void on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
ResponseStatus status) override {
this->custom_count_++;
}
int sent_count_{0};
int write_single_count_{0};
int custom_count_{0};
};
} // namespace
// Completing a broadcast with an empty response({}) used to fall, for a device on the default
// on_response(), through the typed dispatcher to on_custom_response() - firing the wrong callback and
// logging a spurious "non-standard" warning. Fire-and-forget delivers no terminal at all, so a broadcast
// write reaches neither the typed write callback nor the catch-all: only on_sent.
TEST(ModbusClientHubBroadcast, DeliversNoTerminalToTypedDevice) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastTypedProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; // write single register 0x0010 = 0x0001
ASSERT_TRUE(device.queue_pdu(write));
hub.send_next_for_test(); // transmit + sweep
EXPECT_EQ(device.sent_count_, 1); // on_sent still reports the transmission
EXPECT_EQ(device.write_single_count_, 0); // no terminal: the typed write callback never fires
EXPECT_EQ(device.custom_count_, 0); // and it is NOT diverted to the catch-all (no false warning)
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.entries(), 0u);
}
// 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).
TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; // read holding registers 0x0010, count 2
EXPECT_FALSE(device.queue_pdu(read)); // refused: a broadcast read is never answered
EXPECT_EQ(hub.entries(), 0u); // nothing entered the machine
EXPECT_FALSE(hub.waiting());
hub.send_next_for_test(); // nothing to send
EXPECT_EQ(device.sent_count_, 0); // never transmitted
}
// The counterpart to RefusesReadBroadcast: a custom (user-defined) function code carries no reply the
// hub knows how to expect, so a broadcast of one is accepted and completes fire-and-forget like a write.
TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t custom[] = {0x41, 0x01, 0x02}; // FC 0x41: first user-defined function code space
ASSERT_TRUE(device.queue_pdu(custom)); // accepted: a custom code is not a read
EXPECT_EQ(hub.queued_frames(), 1u);
hub.send_next_for_test(); // transmit + sweep
EXPECT_EQ(device.sent_count_, 1); // the frame went on the wire
EXPECT_EQ(device.response_count_, 0); // fire-and-forget: no terminal callback
EXPECT_EQ(device.no_response_count_, 0); // and it never waited for a reply
EXPECT_FALSE(hub.waiting());
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.
TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t exception_custom[] = {0xC1, 0x01, 0x02}; // 0x41 | 0x80: custom code with the exception bit
EXPECT_FALSE(device.queue_pdu(exception_custom)); // refused: exception-flagged, never a real broadcast
EXPECT_EQ(hub.entries(), 0u); // nothing entered the machine
EXPECT_FALSE(hub.waiting());
hub.send_next_for_test(); // nothing to send
EXPECT_EQ(device.sent_count_, 0); // never transmitted
}
namespace {
// tx_blocked() clear for send_next_frame_'s gate, then blocked for send_frame_'s post-delay re-check.
class RejectPostDelayHub : public NoResponseProbeHub {
public:
bool tx_blocked() override { return this->tx_blocked_calls_++ > 0; }
int tx_blocked_calls_{0};
};
} // namespace
// A byte arriving during send_frame_'s pre-send delay blocks transmission after the caller's gate
// already passed. send_frame_ rejects, and send_next_frame_ leaves the frame READY to retry - it is
// not marked WAITING and the bus is not claimed.
TEST(ModbusClientHubSent, SendRejectedAfterDelayLeavesFrameReady) {
NullUART uart;
RejectPostDelayHub hub;
hub.set_uart_parent(&uart);
hub.setup();
SentCountingDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
hub.send_next_for_test(); // gate passes, send_frame_ rejects on the post-delay re-check
EXPECT_EQ(device.sent_count_, 0); // nothing transmitted
EXPECT_FALSE(hub.waiting()); // the frame was left untouched, bus not claimed
ASSERT_EQ(hub.entries(), 1u);
EXPECT_EQ(hub.queued(0).state, FrameState::READY); // still selectable next loop
}
// Counts response deliveries so requeue semantics can be pinned end to end.
namespace {
class DataCountingDevice : public ModbusClientDevice {
public:
DataCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->data_count_++;
}
void on_error(std::span<const uint8_t> request_pdu, ExceptionCode exception_code) override { this->error_count_++; }
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_count_++;
this->last_no_response_pdu_.assign(request_pdu.begin(), request_pdu.end());
if (this->retries_ == 0)
return false;
this->retries_--;
return true;
}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
this->last_not_sent_pdu_.assign(request_pdu.begin(), request_pdu.end());
}
void on_sent(std::span<const uint8_t> request_pdu) override { this->sent_count_++; }
int terminals() const {
return this->data_count_ + this->error_count_ + this->no_response_count_ + this->not_sent_count_;
}
int data_count_{0};
int error_count_{0};
int no_response_count_{0};
int not_sent_count_{0};
int sent_count_{0};
int retries_{0};
std::vector<uint8_t> last_not_sent_pdu_;
std::vector<uint8_t> last_no_response_pdu_;
};
// Runs full send/respond cycles until the queue drains; returns the number of cycles executed.
int drain_with_responses(NoResponseProbeHub &hub, std::span<const uint8_t> response_pdu, int max_cycles = 10) {
int cycles = 0;
while (hub.queued_frames() != 0 && cycles < max_cycles) {
hub.force_send_next();
hub.receive_frame_for_test(0x02, response_pdu);
cycles++;
}
return cycles;
}
} // namespace
constexpr uint8_t OK_RESPONSE[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
// One request produces exactly one data callback.
TEST(ModbusClientHubCallbackCount, SingleReadSingleCallback) {
NoResponseProbeHub hub;
DataCountingDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
drain_with_responses(hub, OK_RESPONSE);
EXPECT_EQ(device.data_count_, 1);
EXPECT_EQ(device.not_sent_count_, 0);
EXPECT_EQ(hub.queued_frames(), 0u);
EXPECT_FALSE(hub.waiting());
}
// Requesting the same read twice while queued yields exactly two callbacks:
// the promoted entry completes, re-queues once (demoted), completes again, and stops.
TEST(ModbusClientHubCallbackCount, DuplicateReadExactlyTwoCallbacks) {
NoResponseProbeHub hub;
DataCountingDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
device.queue_pdu(read_pdu());
int cycles = drain_with_responses(hub, OK_RESPONSE);
EXPECT_EQ(cycles, 2);
EXPECT_EQ(device.data_count_, 2);
EXPECT_EQ(device.not_sent_count_, 0); // both requests were served
EXPECT_EQ(hub.queued_frames(), 0u);
}
namespace {
// Clears the address queue from inside its first response callback, so a duplicate still owed on the
// same entry has to be resolved (or, as things stand, is dropped) by that clear.
class ClearOnFirstResponseDevice : public DataCountingDevice {
public:
ClearOnFirstResponseDevice(ModbusClientHub *hub, uint8_t address) : DataCountingDevice(hub, address) {}
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->data_count_++;
if (this->data_count_ == 1)
this->clear_tx_queue_for_address(); // clear mid-completion, from inside the first response
}
};
} // namespace
// A duplicate read absorbs into one entry (pending 2). The first response resolves one request, and
// its callback clears the address queue mid-completion. The still-owed duplicate is a second accepted
// request, so it must get its own terminal - on_not_sent() - not be dropped silently.
TEST(ModbusClientHubCallbackCount, ClearFromResponseResolvesDuplicateWithNotSent) {
NoResponseProbeHub hub;
ClearOnFirstResponseDevice device(&hub, 0x02);
EXPECT_TRUE(device.queue_pdu(read_pdu()));
EXPECT_TRUE(device.queue_pdu(read_pdu())); // absorbed: one entry, pending 2
hub.force_send_next();
hub.receive_frame_for_test(0x02, OK_RESPONSE); // response -> on_response -> clear, then sweep
EXPECT_EQ(device.data_count_, 1); // exactly one response delivered
EXPECT_EQ(device.not_sent_count_, 1); // the duplicate resolved with a terminal, not dropped
EXPECT_EQ(device.terminals(), 2); // one terminal per accepted request
EXPECT_EQ(hub.queued_frames(), 0u);
}
// A read entry serves two requests (this run plus one re-run), so the third identical request is
// refused at the door: two data callbacks, and no terminal for the request that was never taken.
TEST(ModbusClientHubCallbackCount, TripleReadRefusesTheThird) {
NoResponseProbeHub hub;
DataCountingDevice device(&hub, 0x02);
EXPECT_TRUE(device.queue_pdu(read_pdu()));
EXPECT_TRUE(device.queue_pdu(read_pdu()));
EXPECT_FALSE(device.queue_pdu(read_pdu())); // the entry is already at its cap
hub.sweep_for_test();
EXPECT_EQ(device.not_sent_count_, 0); // refused synchronously, nothing owed
int cycles = drain_with_responses(hub, OK_RESPONSE);
EXPECT_EQ(cycles, 2);
EXPECT_EQ(device.data_count_, 2);
EXPECT_EQ(device.terminals(), 2); // exactly one per accepted request
EXPECT_EQ(hub.queued_frames(), 0u);
}
// A duplicate write is refused at the door and the original write sends once - the caller learns
// immediately, and no lifecycle is created for the request that was never taken.
TEST(ModbusClientHubCallbackCount, DuplicateWriteRefusedWithoutLifecycle) {
NoResponseProbeHub hub;
DataCountingDevice device(&hub, 0x02);
const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF};
EXPECT_TRUE(device.queue_pdu(write_pdu));
EXPECT_FALSE(device.queue_pdu(write_pdu));
hub.sweep_for_test();
EXPECT_EQ(device.terminals(), 0); // the accepted write has not resolved; the other never existed
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE);
}
// An exception response is a terminal on its own: exactly one on_error(), no others,
// preceded by exactly one on_sent().
TEST(ModbusClientHubCallbackCount, ErrorResponseIsSoleTerminal) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
hub.send_next_for_test();
const uint8_t exception_response[] = {0x83, 0x02};
hub.receive_frame_for_test(0x02, exception_response);
EXPECT_EQ(device.error_count_, 1);
EXPECT_EQ(device.terminals(), 1);
EXPECT_EQ(device.sent_count_, 1);
}
// A timeout is a terminal on its own: exactly one on_no_response(), preceded by one
// on_sent(); a refused duplicate ends in on_not_sent() with NO on_sent().
TEST(ModbusClientHubCallbackCount, NoResponseIsSoleTerminalAndNotSentHasNoSent) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
hub.send_next_for_test();
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_EQ(device.terminals(), 1);
EXPECT_EQ(device.sent_count_, 1);
// Unabsorbable duplicate: the second identical write is refused at the door - no lifecycle, no
// terminal, nothing sent.
const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF};
EXPECT_TRUE(device.queue_pdu(write_pdu));
EXPECT_FALSE(device.queue_pdu(write_pdu));
hub.sweep_for_test();
EXPECT_EQ(device.not_sent_count_, 0);
EXPECT_EQ(device.terminals(), 1); // still just the read's timeout
EXPECT_EQ(device.sent_count_, 1);
// Drain the accepted write: its echo response is the data terminal, and the books balance.
hub.send_next_for_test();
hub.receive_frame_for_test(0x02, write_pdu);
EXPECT_EQ(device.data_count_, 1);
EXPECT_EQ(device.terminals(), 2); // 2 accepted lifecycles, 2 terminals
EXPECT_EQ(device.sent_count_, 2); // 2 transmissions; the refused duplicate never sent
}
// A device-requested retry starts a new lifecycle: each transmission gets its own sent + terminal.
TEST(ModbusClientHubCallbackCount, RetryLifecyclesEachGetSentAndTerminal) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.retries_ = 1; // ask for exactly one retry
device.queue_pdu(read_pdu());
hub.send_next_for_test();
hub.timeout_waiting(); // lifecycle 1: sent + no_response (retry requested -> re-queued)
ASSERT_EQ(hub.queued_frames(), 1u);
hub.send_next_for_test();
hub.timeout_waiting(); // lifecycle 2: sent + no_response (retry declined -> done)
EXPECT_EQ(device.no_response_count_, 2);
EXPECT_EQ(device.terminals(), 2);
EXPECT_EQ(device.sent_count_, 2);
EXPECT_EQ(hub.queued_frames(), 0u);
// The retried lifecycle's timeout carries the SAME request PDU as the first attempt.
EXPECT_EQ(device.last_no_response_pdu_, std::vector<uint8_t>(READ_PDU, READ_PDU + sizeof(READ_PDU)));
}
// A retry is a state flip on an existing entry, never a new insertion, so a full queue can't refuse
// it: fill the queue, time out the waiting frame with a retry, and it survives as READY.
TEST(ModbusClientHubCallbackCount, RetryIsNeverRefusedByFullQueue) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.retries_ = 1;
SentCountingDevice filler(&hub, 0x05);
device.queue_pdu(read_pdu());
hub.force_send_next(); // waiting
device.queue_pdu(read_pdu()); // absorbed: two requests pending
// Fill the remaining live capacity with distinct frames.
for (uint16_t i = 0; hub.entries() < MODBUS_TX_BUFFER_SIZE; i++) {
const uint8_t fill[] = {0x03, static_cast<uint8_t>(i >> 8), static_cast<uint8_t>(i & 0xFF), 0x00, 0x01};
filler.queue_pdu(fill);
}
hub.timeout_waiting(); // retry requested; the entry flips back to READY regardless of capacity
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_EQ(device.not_sent_count_, 0); // nothing was refused
ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
const ModbusDeviceCommand *next = hub.next_ready();
ASSERT_NE(next, nullptr);
EXPECT_EQ(next->device, &filler); // round-robin: the retry re-stamped behind the fillers
// The retried entry survives as READY with both absorbed requests intact.
bool found = false;
for (size_t i = 0; i < hub.queued_frames(); i++) {
const ModbusDeviceCommand &cmd = hub.queued(i);
if (cmd.device == &device) {
EXPECT_EQ(cmd.pending, 2u);
found = true;
}
}
EXPECT_TRUE(found);
}
// The deprecated device-side send_raw() reports an unusable payload the same way every other
// refused send does: false at the call site, with no queue entry and no callback.
namespace {
class NotSentCountingRawDevice : public ModbusClientDevice {
public:
NotSentCountingRawDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override { this->not_sent_count_++; }
int not_sent_count_{0};
};
} // namespace
TEST(ModbusClientHubQueue, SendRawTooShortIsRefusedAtTheDoor) {
NoResponseProbeHub hub;
NotSentCountingRawDevice device(&hub, 0x02);
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
device.send_raw({}); // too short to contain a PDU; the deprecated void spelling cannot report it
#pragma GCC diagnostic pop
EXPECT_EQ(device.not_sent_count_, 0); // refused at the door: no callback delivered
EXPECT_TRUE(hub.tx_buffer_empty()); // the only evidence of the refusal is that nothing queued
}
// A continuous read: every wire transmission pairs one sent with one terminal, ending on the error.
TEST(ModbusClientHubCallbackCount, ContinuousLifecyclesBalance) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.read_holding_registers(0x100, 2, {.continuous = true});
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
const uint8_t exception_response[] = {0x83, 0x02};
hub.send_next_for_test();
hub.receive_frame_for_test(0x02, ok_response); // lifecycle 1 -> requeued
hub.send_next_for_test();
hub.receive_frame_for_test(0x02, ok_response); // lifecycle 2 -> requeued
hub.send_next_for_test();
hub.receive_frame_for_test(0x02, exception_response); // lifecycle 3 -> stops
EXPECT_EQ(device.data_count_, 2);
EXPECT_EQ(device.error_count_, 1);
EXPECT_EQ(device.terminals(), 3);
EXPECT_EQ(device.sent_count_, 3);
EXPECT_EQ(hub.queued_frames(), 0u);
}
namespace {
// A device that stops itself (clears its own queue) from inside on_response().
class ClearOnDataDevice : public ModbusClientDevice {
public:
ClearOnDataDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->clear_tx_queue_for_device();
}
};
// A device that chains a follow-up send from inside on_sent().
class ChainOnSentDevice : public ModbusClientDevice {
public:
ChainOnSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_sent(std::span<const uint8_t> request_pdu) override {
if (!this->chained_) {
this->chained_ = true;
const uint8_t follow[] = {0x03, 0x00, 0x09, 0x00, 0x01}; // read holding 0x0009 x1
this->queue_pdu(follow);
}
}
bool chained_{false};
};
} // namespace
// clear_tx_queue_for_address() resolves every dropped frame via its owner's on_not_sent(), so a device
// sharing the address with the clearer (e.g. a modbus_client action alongside an offline controller)
// observes the drop; frames for other addresses are untouched.
TEST(ModbusClientHubQueue, ClearAddressQueueNotifiesEveryOwner) {
NoResponseProbeHub hub;
SentCountingDevice controller_like(&hub, 0x02);
SentCountingDevice bystander_same(&hub, 0x02);
SentCountingDevice bystander_other(&hub, 0x03);
const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02};
const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02};
const uint8_t read_c[] = {0x03, 0x03, 0x00, 0x00, 0x02};
controller_like.queue_pdu(read_a);
bystander_same.queue_pdu(read_b);
bystander_other.queue_pdu(read_c);
ASSERT_EQ(hub.queued_frames(), 3u);
controller_like.clear_tx_queue_for_address();
hub.sweep_for_test(); // the loop's sweep delivers the owed terminals and erases the entries
ASSERT_EQ(hub.queued_frames(), 1u); // only the other-address frame remains
EXPECT_EQ(hub.queued(0).frame.address(), 0x03);
EXPECT_EQ(controller_like.not_sent_count_, 1);
EXPECT_EQ(bystander_same.not_sent_count_, 1);
EXPECT_EQ(bystander_other.not_sent_count_, 0);
// each owner saw its own request PDU
EXPECT_EQ(bystander_same.last_not_sent_pdu_, std::vector<uint8_t>(std::begin(read_b), std::end(read_b)));
}
// A cleared entry resolves with one on_not_sent() per accepted request it stood for, so the
// books balance for owners counting outstanding requests - all within the one sweep.
TEST(ModbusClientHubQueue, ClearAddressDeliversOneTerminalPerAcceptedRequest) {
NoResponseProbeHub hub;
SentCountingDevice device(&hub, 0x02);
const uint8_t read[] = {0x03, 0x01, 0x00, 0x00, 0x02};
device.queue_pdu(read);
device.queue_pdu(read); // duplicate: absorbed into the queued entry
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_EQ(hub.queued(0).pending, 2u);
hub.clear_tx_queue_for_address(0x02);
hub.sweep_for_test();
EXPECT_EQ(device.not_sent_count_, 2); // one terminal per accepted request
EXPECT_EQ(hub.queued_frames(), 0u);
EXPECT_EQ(hub.entries(), 0u); // fully drained and erased
}
// A duplicate read absorbs into one waiting entry (pending 2 once the first is sent). A clear with
// clear_sent detaches the in-flight frame as a silent shell, but the duplicate - a second accepted
// request that would have re-run - was never transmitted, so it must still get its on_not_sent().
TEST(ModbusClientHubQueue, ClearSentOnInFlightDuplicateStillNotifiesTheDuplicate) {
NoResponseProbeHub hub;
DataCountingDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
device.queue_pdu(read_pdu()); // absorbed: one entry, pending 2
ASSERT_EQ(hub.queued(0).pending, 2u);
hub.force_send_next(); // the frame is sent (WAITING); pending still 2
ASSERT_TRUE(hub.waiting());
hub.clear_tx_queue_for_address(0x02);
hub.sweep_for_test();
EXPECT_EQ(device.not_sent_count_, 1); // the un-transmitted duplicate is resolved, not dropped
}
// A clear does not abandon the in-flight frame: it becomes a WAITING_RETIRED shell that keeps the
// bus and still delivers the in-flight request's usual callback (here on_response) when the reply
// arrives. Only un-run duplicates are turned into on_not_sent(); a lone in-flight frame has none.
TEST(ModbusClientHubQueue, ClearWhileInFlightStillDeliversTheResponse) {
NoResponseProbeHub hub;
DataCountingDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
hub.force_send_next(); // sent, now WAITING
ASSERT_TRUE(hub.waiting());
hub.clear_tx_queue_for_address(0x02);
hub.sweep_for_test();
EXPECT_EQ(device.not_sent_count_, 0); // no un-run duplicate to resolve
ASSERT_TRUE(hub.waiting()); // still waiting for a response, holding the bus
ASSERT_EQ(hub.entries(), 1u); // entry preserved as a cleared shell
EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED);
// the in-flight request still gets its usual callback when the response finally arrives
hub.receive_frame_for_test(0x02, OK_RESPONSE);
EXPECT_EQ(device.data_count_, 1);
EXPECT_EQ(device.not_sent_count_, 0);
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.entries(), 0u);
}
namespace {
// Re-sends its frame once from inside on_not_sent - the re-queued frame must survive the sweep.
class ResendOnNotSentDevice : public ModbusClientDevice {
public:
ResendOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
if (this->not_sent_count_ == 1) {
const uint8_t again[] = {0x06, 0x00, 0x40, 0x00, 0x01}; // a write: ranked first at selection, not by position
this->queue_pdu(again);
}
}
int not_sent_count_{0};
};
} // namespace
// A handler that re-sends to the same address from inside on_not_sent() neither corrupts the sweep
// nor loops it: the fresh entry starts within its cap, so the sweep never touches it.
TEST(ModbusClientHubQueue, ClearAddressReentrantResendSurvives) {
NoResponseProbeHub hub;
ResendOnNotSentDevice device(&hub, 0x02);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
device.queue_pdu(read);
ASSERT_EQ(hub.queued_frames(), 1u);
hub.clear_tx_queue_for_address(0x02);
hub.sweep_for_test();
// The original frame resolved via on_not_sent; the re-send from inside that callback remains queued.
EXPECT_EQ(device.not_sent_count_, 1);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).frame.address(), 0x02);
}
// The hard case for the sweep: the notified handler re-queues a WRITE to the cleared address. The
// fresh entry must be neither dropped nor re-notified - and the bystander's frame at the other
// address survives untouched, while the write still wins transmit selection.
TEST(ModbusClientHubQueue, ClearAddressReentrantResendNotSwept) {
NoResponseProbeHub hub;
ResendOnNotSentDevice resender(&hub, 0x02);
SentCountingDevice bystander_other(&hub, 0x03);
const uint8_t read_victim[] = {0x03, 0x00, 0x10, 0x00, 0x01};
const uint8_t read_other[] = {0x03, 0x00, 0x20, 0x00, 0x01};
resender.queue_pdu(read_victim);
bystander_other.queue_pdu(read_other);
ASSERT_EQ(hub.queued_frames(), 2u);
hub.clear_tx_queue_for_address(0x02);
hub.sweep_for_test();
EXPECT_EQ(resender.not_sent_count_, 1); // notified once, never re-notified for the re-send
ASSERT_EQ(hub.queued_frames(), 2u); // the re-queued write AND the other-address read survive
const ModbusDeviceCommand *next = hub.next_ready();
ASSERT_NE(next, nullptr);
EXPECT_EQ(next->frame.address(), 0x02); // the WRITE class wins selection over the older read
EXPECT_EQ(next->frame.pdu()[0], 0x06);
}
namespace {
// Re-sends its own frame from EVERY on_not_sent. There is no serve/absorb treadmill: a duplicate at
// the servable cap is refused at the door, and a re-send issued while the entry is retiring queues a
// fresh entry beyond the sweep's captured work_set (served next sweep), never re-absorbing the one draining.
class AlwaysResendDevice : public ModbusClientDevice {
public:
AlwaysResendDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
const uint8_t again[] = {0x03, 0x00, 0x50, 0x00, 0x01};
this->queue_pdu(again);
}
int not_sent_count_{0};
};
// From inside on_not_sent, clears ANOTHER address - those victims must still be notified. Nothing
// suppresses that: a re-entrant clear only flips states, retire() is a no-op on an already-retired
// entry, and each entry still owes one notification per un-run request until pending reaches zero.
class ClearOtherOnNotSentDevice : public ModbusClientDevice {
public:
ClearOtherOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
this->parent_->clear_tx_queue_for_address(0x03);
}
int not_sent_count_{0};
};
} // namespace
// pending can never exceed what the entry can serve, so the old serve/absorb treadmill is
// impossible by construction: the surplus request is refused at the door instead of being absorbed
// and resolved later, and a handler that re-sends gets false rather than another lifecycle.
TEST(ModbusClientHubQueue, PendingNeverExceedsTheServableCap) {
NoResponseProbeHub hub;
AlwaysResendDevice device(&hub, 0x02);
const uint8_t read[] = {0x03, 0x00, 0x50, 0x00, 0x01};
EXPECT_TRUE(device.queue_pdu(read));
EXPECT_TRUE(device.queue_pdu(read));
EXPECT_FALSE(device.queue_pdu(read)); // at the cap: refused
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).pending, 2u);
hub.sweep_for_test(); // nothing is owed, so the handler never runs
EXPECT_EQ(device.not_sent_count_, 0);
EXPECT_EQ(hub.queued(0).pending, 2u);
}
// A full queue refuses at the door: false at the call site, no entry, no callback - so the
// refusal cannot re-enter the hub at all and needs no recursion bound of its own.
TEST(ModbusClientHubQueue, FullQueueRefusesWithoutCallbacks) {
NoResponseProbeHub hub;
SentCountingDevice filler(&hub, 0x05);
SentCountingDevice device(&hub, 0x02);
// Fill the queue with distinct frames (distinct start addresses keep the dedup from absorbing them).
for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) {
const uint8_t fill[] = {0x03, static_cast<uint8_t>(i >> 8), static_cast<uint8_t>(i & 0xFF), 0x00, 0x01};
filler.queue_pdu(fill);
}
ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
EXPECT_FALSE(device.queue_pdu(read)); // refused synchronously
hub.sweep_for_test();
EXPECT_EQ(device.not_sent_count_, 0); // nothing was accepted, so nothing is owed
EXPECT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
EXPECT_EQ(hub.entries(), MODBUS_TX_BUFFER_SIZE); // and no refusal bookkeeping was stored
}
namespace {
// From inside on_not_sent, clears its OWN address - its remaining queued frames resolve silently
// (the guard suppresses self-deliveries), while other owners on the address are still notified.
class ClearOwnAddressOnNotSentDevice : public ModbusClientDevice {
public:
ClearOwnAddressOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
this->clear_tx_queue_for_address();
}
int not_sent_count_{0};
};
} // namespace
// An address clear issued from inside on_not_sent() resolves EVERY dropped request with its own
// terminal at the sweep - including the clearer's (the sweep delivers from a quiescent hub, so the
// old stack-nesting silence no longer applies; use clear_tx_queue_for_device() for silent teardown).
TEST(ModbusClientHubQueue, SelfClearFromNotSentResolvesEveryRequest) {
NoResponseProbeHub hub;
ClearOwnAddressOnNotSentDevice clearer(&hub, 0x02);
SentCountingDevice bystander(&hub, 0x02);
const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01};
const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01};
const uint8_t read_c[] = {0x03, 0x00, 0x30, 0x00, 0x01};
clearer.queue_pdu(read_a);
clearer.queue_pdu(read_b);
bystander.queue_pdu(read_c);
ASSERT_EQ(hub.queued_frames(), 3u);
EXPECT_FALSE(clearer.queue_pdu(std::span<const uint8_t>{})); // empty: refused, no callback
clearer.clear_tx_queue_for_address(); // the clear the handler used to make
hub.sweep_for_test();
EXPECT_EQ(clearer.not_sent_count_, 2); // one per cleared request of its own
EXPECT_EQ(bystander.not_sent_count_, 1); // the bystander's cleared frame is notified too
EXPECT_EQ(hub.queued_frames(), 0u);
EXPECT_EQ(hub.entries(), 0u);
}
// A clear issued from inside on_not_sent() still delivers its victims' notifications in the same sweep:
// the newly-retired entries set sweep_needed_ and the sweep's restart loop drains them before it ends.
TEST(ModbusClientHubQueue, NestedClearFromNotSentStillNotifiesVictims) {
NoResponseProbeHub hub;
ClearOtherOnNotSentDevice clearer(&hub, 0x02);
SentCountingDevice victim(&hub, 0x03);
const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01};
const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01};
clearer.queue_pdu(read_a);
victim.queue_pdu(read_b);
ASSERT_EQ(hub.queued_frames(), 2u);
hub.clear_tx_queue_for_address(0x02); // clearer's on_not_sent clears address 0x03 in turn
hub.sweep_for_test();
EXPECT_EQ(clearer.not_sent_count_, 1);
EXPECT_EQ(victim.not_sent_count_, 1); // the nested clear's victim resolves in the same sweep
EXPECT_EQ(hub.queued_frames(), 0u);
}
namespace {
// From on_not_sent (delivered by the sweep), re-sends a frame identical to ANOTHER doomed queued
// frame; the dedup must not absorb into the doomed entry.
class ResendSecondFrameDevice : public ModbusClientDevice {
public:
ResendSecondFrameDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
if (this->not_sent_count_ == 1) {
const uint8_t same_as_r2[] = {0x03, 0x00, 0x22, 0x00, 0x01};
this->queue_pdu(same_as_r2);
}
}
int not_sent_count_{0};
};
} // namespace
// A send during a sweep that matches a DELETED (doomed) frame must queue fresh, not absorb into
// the doomed entry - absorption would tie the new request to a frame the sweep is draining.
TEST(ModbusClientHubQueue, SweepDedupSkipsDeletedFrames) {
NoResponseProbeHub hub;
ResendSecondFrameDevice device(&hub, 0x02);
const uint8_t r1[] = {0x03, 0x00, 0x21, 0x00, 0x01};
const uint8_t r2[] = {0x03, 0x00, 0x22, 0x00, 0x01};
device.queue_pdu(r1);
device.queue_pdu(r2);
ASSERT_EQ(hub.queued_frames(), 2u);
hub.clear_tx_queue_for_address(0x02);
hub.sweep_for_test(); // r1 and r2 both resolve; r1's handler re-sends a frame identical to r2
// Without the dedup's dead-state skip the re-send would be absorbed into r2 and drained with it;
// with the skip it queues fresh and survives.
EXPECT_EQ(device.not_sent_count_, 2); // r1 and r2 both resolved
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survives
EXPECT_EQ(hub.queued(0).frame.pdu()[2], 0x22);
}
namespace {
// The worst-case handler: from every on_not_sent() it both re-sends and clears its own address, so
// each delivery manufactures a fresh entry AND a fresh terminal debt.
class ResendAndClearOnNotSentDevice : public ModbusClientDevice {
public:
ResendAndClearOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
const uint8_t again[] = {0x03, 0x00, 0x70, 0x00, 0x01};
this->queue_pdu(again);
this->clear_tx_queue_for_address();
}
int not_sent_count_{0};
};
} // namespace
// Sweep-termination worst case: a handler re-sending AND clearing from every on_not_sent() still
// can't extend the sweep, since it serves only the entries it started with (new debt waits).
TEST(ModbusClientHubQueue, ResendAndClearFromNotSentCannotExtendTheSweep) {
NoResponseProbeHub hub;
ResendAndClearOnNotSentDevice device(&hub, 0x02);
const uint8_t read[] = {0x03, 0x00, 0x70, 0x00, 0x01};
device.queue_pdu(read);
hub.clear_tx_queue_for_address(0x02);
hub.sweep_for_test();
EXPECT_EQ(device.not_sent_count_, 1); // exactly the one terminal that was owed on entry
EXPECT_EQ(hub.entries(), 1u); // the frame the handler queued (and then cleared itself)
hub.sweep_for_test();
EXPECT_EQ(device.not_sent_count_, 2); // its terminal comes on the next loop, not this sweep
EXPECT_EQ(hub.entries(), 1u); // and the container is still not growing
hub.sweep_for_test();
EXPECT_EQ(device.not_sent_count_, 3);
EXPECT_EQ(hub.entries(), 1u);
}
// clear_tx_queue_for_device() drops queued frames SILENTLY - no terminal callback (the documented
// exception to the exactly-one-terminal contract; used during teardown/offline handling).
TEST(ModbusClientHubQueue, ClearDeviceQueueDropsSilently) {
NoResponseProbeHub hub;
SentCountingDevice device(&hub, 0x02);
const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02};
const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02};
device.queue_pdu(read_a);
device.queue_pdu(read_b);
ASSERT_EQ(hub.queued_frames(), 2u);
device.clear_tx_queue_for_device();
EXPECT_EQ(hub.queued_frames(), 0u);
EXPECT_EQ(device.not_sent_count_, 0); // silent drop: no terminal callback
}
// A queue_pdu() from inside on_sent() enqueues behind the waiting frame rather than sending
// immediately or corrupting the waiting transaction.
TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
ChainOnSentDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
hub.send_next_for_test(); // first frame is sent -> on_sent chains a follow-up
EXPECT_TRUE(hub.waiting()); // first frame is waiting
ASSERT_EQ(hub.queued_frames(), 1u); // the follow-up queued behind it, not sent
EXPECT_EQ(hub.queued(0).frame.pdu()[2], 0x09); // it is the chained read (start address 0x0009)
}
// "Stop polling now" from inside on_response() works: the completing command is exposed to the
// clear routines, which detach it, cancelling the pending continuous re-queue.
TEST(ModbusClientHubPriority, ClearDeviceDuringDataCancelsContinuousRequeue) {
NoResponseProbeHub hub;
ClearOnDataDevice device(&hub, 0x02);
device.read_holding_registers(0x100, 2, {.continuous = true});
hub.force_send_next();
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
// "Stop polling now" from inside on_response() works: the completing command is detached, so the
// continuous re-queue is cancelled.
EXPECT_EQ(hub.queued_frames(), 0u);
}
namespace {
// A device that stops polling for its address (clear by address) from inside on_response().
class ClearAddressOnDataDevice : public ModbusClientDevice {
public:
ClearAddressOnDataDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->clear_tx_queue_for_address();
}
};
} // namespace
// The address-scoped clear cancels the mid-completion re-queue the same way the device-scoped one does.
TEST(ModbusClientHubPriority, ClearAddressDuringDataCancelsContinuousRequeue) {
NoResponseProbeHub hub;
ClearAddressOnDataDevice device(&hub, 0x02);
device.read_holding_registers(0x100, 2, {.continuous = true});
hub.force_send_next();
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
EXPECT_EQ(hub.queued_frames(), 0u);
}
namespace {
// Overrides only the DEPRECATED on_modbus_* names: the new-name default implementations must forward, so
// external devices written against the old names keep working through the deprecation window.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
class LegacyNameDevice : public ModbusClientDevice {
public:
LegacyNameDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_modbus_not_sent() override { this->legacy_not_sent_++; }
bool on_modbus_no_response() override {
this->legacy_no_response_++;
return false;
}
int legacy_not_sent_{0};
int legacy_no_response_{0};
};
#pragma GCC diagnostic pop
} // namespace
// send_pdu() was renamed queue_pdu() because the call queues a request rather than transmitting one.
// The old spelling stays for the deprecation window with the signature 2026.7.4 shipped - void, no
// CommandOptions - so a component built against a real release still compiles and still queues.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
TEST(ModbusClientHubCompat, DeprecatedSendPduStillQueues) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/false);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
device.send_pdu(read); // deprecated device spelling: void, as 2026.7.4 shipped it
EXPECT_EQ(hub.queued_frames(), 1u);
// A refusal is invisible to this spelling - no return value and no callback - so the only evidence
// is that nothing was queued. Reporting the refusal is exactly what moving to queue_pdu() buys.
device.send_pdu(std::span<const uint8_t>());
EXPECT_EQ(hub.queued_frames(), 1u);
// The deprecated hub spelling queues the same way, addressed explicitly.
const uint8_t other[] = {0x03, 0x00, 0x20, 0x00, 0x01};
hub.send_pdu(0x03, other, &device);
EXPECT_EQ(hub.queued_frames(), 2u);
// Both frames resolve to the same owner. Drain them in turn: the device-spelling frame first (FIFO),
// then the hub-spelling frame - addressed to 0x03 yet owned by &device, so reaching device's
// on_no_response proves the request routes by owner pointer, not by address.
hub.force_send_next();
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1); // device-spelling frame (address 0x02)
hub.force_send_next();
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 2); // hub-spelling frame (address 0x03, &device routing)
}
#pragma GCC diagnostic pop
TEST(ModbusClientHubCompat, LegacyCallbackNamesStillForward) {
NoResponseProbeHub hub;
LegacyNameDevice device(&hub, 0x02);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
device.queue_pdu(read);
hub.force_send_next();
hub.timeout_waiting(); // no reply -> on_no_response -> forwards to on_modbus_no_response
EXPECT_EQ(device.legacy_no_response_, 1);
// A refused send returns false with no callback, so exercise the forward through an accepted
// request instead: a cleared queue entry delivers on_not_sent(), which forwards to the old name.
EXPECT_FALSE(device.queue_pdu(std::span<const uint8_t>())); // empty PDU: refused at the door
EXPECT_EQ(device.legacy_not_sent_, 0);
const uint8_t queued[] = {0x03, 0x00, 0x11, 0x00, 0x01};
EXPECT_TRUE(device.queue_pdu(queued));
hub.clear_tx_queue_for_address(0x02);
hub.sweep_for_test();
EXPECT_EQ(device.legacy_not_sent_, 1);
}
// The queue_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU
// 256-byte limit, so it is refused up front - false at the call site, no entry, no callback.
TEST(ModbusClientHub, OversizedPduIsRefusedAtTheDoor) {
NoResponseProbeHub hub;
LegacyNameDevice device(&hub, 0x02);
std::vector<uint8_t> big(MAX_PDU_SIZE + 1, 0x41);
EXPECT_FALSE(device.queue_pdu(big));
EXPECT_EQ(device.legacy_not_sent_, 0); // refusals are returned, never delivered
EXPECT_TRUE(hub.tx_buffer_empty());
EXPECT_EQ(hub.entries(), 0u);
}
// --- ModbusDevice compatibility shim ------------------------------------------------------------
// External components written against the pre-2026.8 API subclass ModbusDevice and override the
// old callbacks; the shim adapts the span-based hooks back to those signatures.
namespace {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
class LegacyApiDevice : public ModbusDevice {
public:
LegacyApiDevice(ModbusClientHub *hub, uint8_t address) : ModbusDevice(hub, address) {}
void on_modbus_data(const std::vector<uint8_t> &data) override { this->last_data_ = data; }
void on_modbus_error(uint8_t function_code, uint8_t exception_code) override {
this->last_error_fc_ = function_code;
this->last_error_code_ = exception_code;
}
std::vector<uint8_t> last_data_;
int last_error_fc_{-1};
int last_error_code_{-1};
};
#pragma GCC diagnostic pop
} // namespace
TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) {
NoResponseProbeHub hub;
LegacyApiDevice device(&hub, 0x02);
// Read response: on_modbus_data() historically received the payload after the function code and
// the byte-count byte, as an owning vector.
const uint8_t read_req[] = {0x03, 0x00, 0x10, 0x00, 0x02};
device.queue_pdu(read_req);
hub.force_send_next();
const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, response);
const std::vector<uint8_t> expected{0x00, 0x2A, 0x01, 0x00};
EXPECT_EQ(device.last_data_, expected);
// Write echo: no byte-count byte, so the payload is everything after the function code.
const uint8_t write_req[] = {0x06, 0x00, 0x10, 0x00, 0x2A};
device.queue_pdu(write_req);
hub.force_send_next();
hub.receive_frame_for_test(0x02, write_req); // single-write responses echo the request
const std::vector<uint8_t> expected_echo{0x00, 0x10, 0x00, 0x2A};
EXPECT_EQ(device.last_data_, expected_echo);
// Exception response: on_modbus_error() received the masked function code and the exception code.
device.queue_pdu(read_req);
hub.force_send_next();
const uint8_t error[] = {0x83, 0x02};
hub.receive_frame_for_test(0x02, error);
EXPECT_EQ(device.last_error_fc_, 0x03);
EXPECT_EQ(device.last_error_code_, 0x02);
}
// --- typed send helpers --------------------------------------------------------------------------
// Each helper is a one-line forward onto a merged builder; these pin the function code and wire
// bytes each one queues, so a swapped code or transposed field cannot survive review silently.
TEST(ModbusTypedSendHelpers, HelpersQueueExpectedPdus) {
NoResponseProbeHub hub;
ModbusClientDevice device(&hub, 0x02);
auto check = [&](const std::vector<uint8_t> &expected) {
ASSERT_EQ(hub.queued_frames(), 1u);
auto pdu = hub.queued(0).frame.pdu();
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
hub.force_send_next();
hub.timeout_waiting(); // default on_no_response() declines the retry, dropping the frame
};
device.read_holding_registers(0x0102, 3);
check({0x03, 0x01, 0x02, 0x00, 0x03});
device.read_input_registers(0x0010, 2);
check({0x04, 0x00, 0x10, 0x00, 0x02});
device.read_coils(0x0020, 10);
check({0x01, 0x00, 0x20, 0x00, 0x0A});
device.read_discrete_inputs(0x0030, 1);
check({0x02, 0x00, 0x30, 0x00, 0x01});
device.write_single_register(0x0040, 0xABCD);
check({0x06, 0x00, 0x40, 0xAB, 0xCD});
device.write_single_coil(0x0041, true);
check({0x05, 0x00, 0x41, 0xFF, 0x00});
device.write_single_coil(0x0041, false);
check({0x05, 0x00, 0x41, 0x00, 0x00});
const uint16_t regs[] = {0x000B, 0x0016};
device.write_multiple_registers(0x0050, regs);
check({0x10, 0x00, 0x50, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16});
const bool coils[] = {true, false, true};
device.write_multiple_coils(0x0060, coils);
check({0x0F, 0x00, 0x60, 0x00, 0x03, 0x01, 0x05});
const uint8_t packed[] = {0x05};
device.write_multiple_coils(0x0060, PackedBits(packed, 3)); // packed overload, same wire bytes
check({0x0F, 0x00, 0x60, 0x00, 0x03, 0x01, 0x05});
}
TEST(ModbusTypedSendHelpers, ReadEntitiesDispatchesByTypeAndRejectsInvalid) {
NoResponseProbeHub hub;
ModbusClientDevice device(&hub, 0x02);
device.read_entities(EntityType::HOLDING, 0x0001, 1);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).frame.pdu()[0], 0x03);
hub.force_send_next();
hub.timeout_waiting();
device.read_entities(EntityType::DISCRETE_INPUT, 0x0001, 1);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).frame.pdu()[0], 0x02);
hub.force_send_next();
hub.timeout_waiting();
device.read_entities(EntityType::CUSTOM, 0x0001, 1); // no read function: logged and not queued
EXPECT_EQ(hub.queued_frames(), 0u);
}
// A rejected read_entities() returns false like every other refused send.
namespace {
class NotSentCountingDevice : public ModbusClientDevice {
public:
NotSentCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override { this->not_sent_++; }
int not_sent_{0};
};
} // namespace
TEST(ModbusTypedSendHelpers, InvalidReadEntitiesIsRefusedAtTheDoor) {
NoResponseProbeHub hub;
NotSentCountingDevice device(&hub, 0x02);
EXPECT_FALSE(device.read_entities(EntityType::CUSTOM, 0x0001, 1));
EXPECT_EQ(device.not_sent_, 0); // refused sends report through the return value
EXPECT_EQ(hub.queued_frames(), 0u);
}
namespace {
// Re-sends its own frame from inside on_response() - matching the command mid-completion.
class ResendOnDataDevice : public ModbusClientDevice {
public:
ResendOnDataDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->queue_pdu(std::vector<uint8_t>(request_pdu.begin(), request_pdu.end()));
}
void queue_pdu(const std::vector<uint8_t> &pdu) { ModbusClientDevice::queue_pdu(pdu); }
};
} // namespace
// A send from inside on_response() that matches the RECEIVED (completing) entry is absorbed into it,
// never a fresh twin. Here it is a one-shot re-send of a continuous poll, so it also downgrades the
// poll to a one-shot: one entry on the queue afterwards, now non-continuous.
TEST(ModbusClientHubPriority, ResendFromOnResponseAbsorbsIntoCompletingCommand) {
NoResponseProbeHub hub;
ResendOnDataDevice device(&hub, 0x02);
device.read_holding_registers(0x100, 2, {.continuous = true});
hub.force_send_next();
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response); // handler re-sends the identical frame mid-completion
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
EXPECT_FALSE(hub.queued(0).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) {
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
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
}
namespace {
// From inside the sweep's on_not_sent, re-sends the frame that is currently WAITING.
class ResendInFlightOnNotSentDevice : public ModbusClientDevice {
public:
ResendInFlightOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
if (this->not_sent_count_ == 1) {
const uint8_t same_as_waiting[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // == READ_PDU
this->queue_pdu(same_as_waiting);
}
}
int not_sent_count_{0};
};
} // namespace
// A clear turns the waiting entry into a WAITING_RETIRED shell. The shell keeps its device (so the
// in-flight request still gets its callback), but the dedup skips it, so a sweep handler re-sending
// that frame queues fresh instead of being absorbed into the cleared shell and drained as on_not_sent.
TEST(ModbusClientHubQueue, SweepResendAfterClearQueuesFreshNotAbsorbedIntoShell) {
NoResponseProbeHub hub;
ResendInFlightOnNotSentDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
hub.force_send_next(); // READ_PDU now waiting
const uint8_t queued_read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
device.queue_pdu(queued_read); // a queued frame for the sweep to notify
ASSERT_EQ(hub.queued_frames(), 1u);
hub.clear_tx_queue_for_address(0x02);
hub.sweep_for_test();
EXPECT_EQ(device.not_sent_count_, 1); // only the cleared queued frame, not the re-send
ASSERT_EQ(hub.queued_frames(), 1u); // the handler's re-send queued fresh...
EXPECT_EQ(hub.queued(0).pending, 1u); // ...not absorbed into the cleared shell
EXPECT_TRUE(std::equal(hub.queued(0).frame.pdu().begin(), hub.queued(0).frame.pdu().end(), READ_PDU));
EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); // in-flight one still awaiting a reply
}
namespace {
// Gives up after a timeout by clearing its address from inside on_no_response() - the natural
// "device is dead, drop my traffic" pattern, and the reentrant case the address clear must handle.
class ClearAddressOnNoResponseDevice : public ModbusClientDevice {
public:
ClearAddressOnNoResponseDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_count_++;
this->clear_tx_queue_for_address();
return false; // gave up
}
void on_not_sent(std::span<const uint8_t> request_pdu) override { this->not_sent_count_++; }
int terminals() const { return this->no_response_count_ + this->not_sent_count_; }
int no_response_count_{0};
int not_sent_count_{0};
};
} // namespace
// A clear issued from inside on_no_response() must not cause the request to be resolved twice:
// that callback already was its terminal, so the entry it hijacks owes nothing more.
TEST(ModbusClientHubNoResponse, SelfClearFromNoResponseDoesNotDoubleResolve) {
NoResponseProbeHub hub;
ClearAddressOnNoResponseDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
hub.force_send_next();
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_EQ(device.terminals(), 1); // exactly one terminal for the one accepted request
EXPECT_EQ(hub.entries(), 0u);
}
// The same entry standing for two accepted requests: the timeout resolves one, and the clear that
// cancels the re-run must resolve exactly the other.
TEST(ModbusClientHubNoResponse, SelfClearFromNoResponseResolvesTheAbsorbedRequestOnce) {
NoResponseProbeHub hub;
ClearAddressOnNoResponseDevice device(&hub, 0x02);
EXPECT_TRUE(device.queue_pdu(read_pdu()));
EXPECT_TRUE(device.queue_pdu(read_pdu())); // absorbed: one entry, two requests
hub.force_send_next();
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_EQ(device.terminals(), 2); // one per accepted request, no more
EXPECT_EQ(hub.entries(), 0u);
}
// A cleared in-flight frame must release the bus by both exits and still deliver the in-flight
// request's usual callback (on_response here, on_no_response on timeout); no on_not_sent, no duplicate.
TEST(ModbusClientHubQueue, ClearedShellReleasesTheBusOnLateResponse) {
NoResponseProbeHub hub;
DataCountingDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
hub.force_send_next();
hub.clear_tx_queue_for_address(0x02);
ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED);
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response); // the late reply for the cleared frame
EXPECT_FALSE(hub.waiting()); // the bus is free again
EXPECT_EQ(hub.entries(), 0u); // the shell is gone
EXPECT_EQ(device.data_count_, 1); // the in-flight request still got its response callback
EXPECT_EQ(device.not_sent_count_, 0); // no un-run duplicate
}
TEST(ModbusClientHubQueue, ClearedShellReleasesTheBusOnTimeout) {
NoResponseProbeHub hub;
DataCountingDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
hub.force_send_next();
hub.clear_tx_queue_for_address(0x02);
ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED);
hub.timeout_waiting(); // no reply ever arrives; the watchdog releases the shell
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.entries(), 0u);
EXPECT_EQ(device.no_response_count_, 1); // the in-flight request got its on_no_response
EXPECT_EQ(device.not_sent_count_, 0); // no un-run duplicate
}
// Clearing an interrupted (not-yet-notified) frame keeps its distrust: it becomes an
// INTERRUPTED_RETIRED shell that still ends in on_no_response at the timeout - never delivering a
// late response as on_response. No duplicate here, so no on_not_sent.
TEST(ModbusClientHubQueue, ClearInterruptedFrameGetsNoResponseAtTimeout) {
NoResponseProbeHub hub;
DataCountingDevice device(&hub, 0x02); // declines the retry (retries_ == 0)
device.queue_pdu(read_pdu());
hub.force_send_next();
const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x07, stray_pdu); // wrong address: interrupts the transaction
hub.sweep_for_test();
ASSERT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED);
hub.clear_tx_queue_for_address(0x02);
ASSERT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED_RETIRED);
// A late MATCHING response is ignored (distrust survives the clear), not delivered as on_response.
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
EXPECT_EQ(device.data_count_, 0);
ASSERT_TRUE(hub.waiting()); // still held; the ignored response did not free the wire
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1); // the interrupted request's usual terminal, at the timeout
EXPECT_EQ(device.not_sent_count_, 0); // no un-run duplicate
EXPECT_EQ(hub.queued_frames(), 0u);
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.entries(), 0u);
}
// The other order: clear a WAITING frame, THEN an unexpected frame arrives. The distrust must still
// take hold - the cleared shell becomes INTERRUPTED_RETIRED and a later matching frame is ignored.
TEST(ModbusClientHubQueue, InterruptAfterClearStillDistrusts) {
NoResponseProbeHub hub;
DataCountingDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
hub.force_send_next();
hub.clear_tx_queue_for_address(0x02);
ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED);
const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x07, stray_pdu); // unexpected frame interrupts the cleared shell
ASSERT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED_RETIRED);
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response); // now-distrusted late response is ignored
EXPECT_EQ(device.data_count_, 0);
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.entries(), 0u);
}
// A cleared waiting duplicate (pending 2) that times out: the duplicate drains to on_not_sent and
// the in-flight request gets on_no_response, with nothing re-transmitted (sweep runs before timeout).
TEST(ModbusClientHubQueue, ClearedInFlightDuplicateTimesOutWithoutRerunning) {
NoResponseProbeHub hub;
DataCountingDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
device.queue_pdu(read_pdu()); // absorbed: one entry, pending 2
ASSERT_EQ(hub.queued(0).pending, 2u);
hub.force_send_next(); // sent, pending still 2
hub.clear_tx_queue_for_address(0x02);
hub.timeout_waiting();
EXPECT_EQ(device.not_sent_count_, 1); // the un-run duplicate
EXPECT_EQ(device.no_response_count_, 1); // the in-flight request's usual terminal
EXPECT_EQ(hub.queued_frames(), 0u); // nothing re-transmitted
EXPECT_EQ(hub.entries(), 0u); // fully drained and erased
EXPECT_FALSE(hub.waiting());
}
// An absorbed extra request also gets its run after an error response - the re-request was
// explicit, so it runs once more whether this attempt succeeded or not.
TEST(ModbusClientHubCallbackCount, AbsorbedRequestRunsAfterErrorResponse) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.queue_pdu(read_pdu());
hub.force_send_next();
device.queue_pdu(read_pdu()); // waiting duplicate: absorbed
const uint8_t exception_response[] = {0x83, 0x02};
hub.receive_frame_for_test(0x02, exception_response); // error terminal for request 1
EXPECT_EQ(device.error_count_, 1);
ASSERT_EQ(hub.queued_frames(), 1u); // request 2's run still queued
EXPECT_EQ(hub.queued(0).pending, 1u);
}
// Read-modify-write function codes mutate registers, so they rank as WRITE for transmit ordering.
TEST(ModbusClientHubPriority, ReadModifyWritesRankAsWrites) {
NoResponseProbeHub hub;
SentCountingDevice device(&hub, 0x02);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
const uint8_t mask_write[] = {0x16, 0x00, 0x10, 0x00, 0xFF, 0x00, 0x01};
device.queue_pdu(read);
device.queue_pdu(mask_write);
ASSERT_EQ(hub.queued_frames(), 2u);
const ModbusDeviceCommand *next = hub.next_ready();
ASSERT_NE(next, nullptr);
EXPECT_EQ(next->priority(), CommandPriority::WRITE); // 0x16 wins selection over the queued read
EXPECT_EQ(next->frame.pdu()[0], 0x16);
}
} // namespace esphome::modbus::testing