[modbus] Command lifecycle: PDU-carrying callbacks, on_sent(), notified queue clearing (#17886)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bonne Eggleston
2026-07-27 10:35:51 -10:00
committed by GitHub
co-authored by Claude Fable 5
parent 56028d0932
commit 19511f5787
5 changed files with 694 additions and 37 deletions
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <cstdint>
#include "esphome/components/uart/uart_component.h"
namespace esphome::modbus::testing {
// A UART that discards all writes, for tests that never inspect the wire.
class NullUART : public uart::UARTComponent {
public:
NullUART() { this->set_baud_rate(115200); }
void write_array(const uint8_t *data, size_t len) override {}
bool peek_byte(uint8_t *data) override { return false; }
bool read_array(uint8_t *data, size_t len) override { return false; }
size_t available() override { return 0; }
uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; }
#if defined(USE_ESP8266) || defined(USE_ESP32)
void load_settings(bool dump_config) override {}
#endif
void check_logger_conflict() override {}
};
} // namespace esphome::modbus::testing
@@ -1,9 +1,13 @@
#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 {
@@ -16,12 +20,14 @@ class NoResponseProbeHub : public ModbusClientHub {
public:
size_t queued_frames() const { return this->tx_buffer_.size(); }
const ModbusDeviceCommand &front() const { return this->tx_buffer_.front(); }
const ModbusDeviceCommand &queued(size_t i) const { return this->tx_buffer_[i]; }
bool waiting() const { return this->waiting_for_response_.has_value(); }
const ModbusDeviceCommand &waiting_command() const {
EXPECT_TRUE(this->waiting_for_response_.has_value());
return *this->waiting_for_response_; // NOLINT(bugprone-unchecked-optional-access)
}
void send_next_for_test() { this->send_next_frame_(); }
void force_send_front() {
this->waiting_for_response_ = std::move(this->tx_buffer_.front());
this->tx_buffer_.pop_front();
@@ -41,7 +47,7 @@ class NoResponseProbeHub : public ModbusClientHub {
class RetryingDevice : public ModbusClientDevice {
public:
RetryingDevice(ModbusClientHub *hub, uint8_t address, bool retry) : ModbusClientDevice(hub, address), retry_(retry) {}
bool on_no_response() override {
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_count_++;
return this->retry_;
}
@@ -55,7 +61,7 @@ class RetryingDevice : public ModbusClientDevice {
class ClearingRetryDevice : public ModbusClientDevice {
public:
ClearingRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
bool on_no_response() override {
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
@@ -176,6 +182,565 @@ TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) {
EXPECT_FALSE(hub.waiting());
}
// 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
// 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.send_pdu(read_pdu());
EXPECT_EQ(device.sent_count_, 0); // queued only - nothing on the wire 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());
}
// 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_front();
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.send_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());
}
// 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.send_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.send_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);
// A refused send (empty PDU) is a not_sent terminal, never sent.
const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF};
device.send_pdu(write_pdu);
device.send_pdu(std::span<const uint8_t>{});
EXPECT_EQ(device.not_sent_count_, 1);
EXPECT_EQ(device.terminals(), 2); // the accepted write is still queued - no terminal for it yet
EXPECT_EQ(device.sent_count_, 1); // and it has not transmitted yet
// Drain it: the write echo response is its 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(), 3); // 3 accepted lifecycles, 3 terminals
EXPECT_EQ(device.sent_count_, 2); // 2 transmissions (read + write); the refused send 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.send_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 re-queue that finds the buffer full is refused like any other send: the device gets
// on_not_sent() carrying the request PDU (the previously uncovered requeue_waiting_frame_ branch).
TEST(ModbusClientHubCallbackCount, FullQueueRetryRefusalDeliversNotSentWithPdu) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.retries_ = 1;
SentCountingDevice filler(&hub, 0x05);
device.send_pdu(read_pdu());
hub.force_send_front(); // in flight
// Fill the queue with distinct frames.
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.send_pdu(fill);
}
ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
hub.timeout_waiting(); // retry requested, but the re-queue is refused: not_sent terminal instead
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_EQ(device.not_sent_count_, 1);
EXPECT_EQ(device.last_not_sent_pdu_, std::vector<uint8_t>(READ_PDU, READ_PDU + sizeof(READ_PDU)));
EXPECT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
}
// The deprecated device-side send_raw() refusal delivers through the same guard as every other
// path: a handler that reacts to its own refusal with another empty send_raw() stays bounded.
namespace {
class SendRawOnNotSentDevice : public ModbusClientDevice {
public:
SendRawOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
this->send_raw({}); // refused again; the guard must suppress the nested delivery
#pragma GCC diagnostic pop
}
int not_sent_count_{0};
};
} // namespace
TEST(ModbusClientHubQueue, SendRawRefusalIsGuardedAgainstRecursion) {
NoResponseProbeHub hub;
SendRawOnNotSentDevice device(&hub, 0x02);
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
device.send_raw({}); // empty payload refused -> on_not_sent -> nested send_raw({}) suppressed
#pragma GCC diagnostic pop
EXPECT_EQ(device.not_sent_count_, 1);
}
namespace {
// 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->send_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.send_pdu(read_a);
bystander_same.send_pdu(read_b);
bystander_other.send_pdu(read_c);
ASSERT_EQ(hub.queued_frames(), 3u);
controller_like.clear_tx_queue_for_address(false);
ASSERT_EQ(hub.queued_frames(), 1u); // only the other-address frame remains
EXPECT_EQ(hub.front().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)));
}
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};
this->send_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: only initially-marked frames are swept, so the re-queued frame stays queued.
TEST(ModbusClientHubQueue, ClearAddressReentrantResendSurvives) {
NoResponseProbeHub hub;
ResendOnNotSentDevice device(&hub, 0x02);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
device.send_pdu(read);
ASSERT_EQ(hub.queued_frames(), 1u);
hub.clear_tx_queue_for_address(0x02, false);
// 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.front().frame.address(), 0x02);
}
namespace {
// Retries from EVERY on_not_sent - against a full queue this recursed without bound before the guard.
class AlwaysRetryDevice : public ModbusClientDevice {
public:
AlwaysRetryDevice(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->send_pdu(again);
}
int not_sent_count_{0};
};
// From inside on_not_sent, clears ANOTHER address - those victims must still be notified (the per-device
// guard suppresses deliveries only to a device already inside its own on_not_sent()).
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, false);
}
int not_sent_count_{0};
};
} // namespace
// A handler that retries from every on_not_sent() against a FULL queue must not recurse: the first
// refusal notifies once, the nested refusal is dropped without a callback (the documented guard).
TEST(ModbusClientHubQueue, FullQueueRetryFromNotSentDoesNotRecurse) {
NoResponseProbeHub hub;
SentCountingDevice filler(&hub, 0x05);
AlwaysRetryDevice retrier(&hub, 0x02);
// Fill the queue with distinct frames.
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.send_pdu(fill);
}
ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
retrier.send_pdu(read); // refused (full) -> on_not_sent -> retry -> refused under the guard, silently
EXPECT_EQ(retrier.not_sent_count_, 1);
EXPECT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
}
namespace {
// From inside on_not_sent, triggers ANOTHER device's send (which will be refused too).
class SendOtherOnNotSentDevice : public ModbusClientDevice {
public:
SendOtherOnNotSentDevice(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->other_ != nullptr) {
const uint8_t read[] = {0x03, 0x00, 0x60, 0x00, 0x01};
this->other_->send_pdu(read);
}
}
ModbusClientDevice *other_{nullptr};
int not_sent_count_{0};
};
} // namespace
// The refusal recursion guard is per-device: a refusal that lands on a DIFFERENT device while one
// device's notification is on the stack must still deliver - that device did not cause the recursion
// and would otherwise silently lose its terminal callback.
TEST(ModbusClientHubQueue, RefusalForOtherDeviceDeliversDuringNotification) {
NoResponseProbeHub hub;
SentCountingDevice filler(&hub, 0x05);
SendOtherOnNotSentDevice first(&hub, 0x02);
SentCountingDevice second(&hub, 0x03);
first.other_ = &second;
// Fill the queue with distinct frames.
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.send_pdu(fill);
}
ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
first.send_pdu(read); // refused -> first.on_not_sent -> second's send refused -> second notified
EXPECT_EQ(first.not_sent_count_, 1);
EXPECT_EQ(second.not_sent_count_, 1);
}
// Two devices whose handlers each trigger the other's send cannot recurse without bound: each device
// can be on the notification stack at most once, so the cycle dies as soon as it returns to a device
// whose own on_not_sent() is still running.
TEST(ModbusClientHubQueue, TwoDeviceRefusalCycleTerminates) {
NoResponseProbeHub hub;
SentCountingDevice filler(&hub, 0x05);
SendOtherOnNotSentDevice first(&hub, 0x02);
SendOtherOnNotSentDevice second(&hub, 0x03);
first.other_ = &second;
second.other_ = &first;
// Fill the queue with distinct frames.
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.send_pdu(fill);
}
ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
first.send_pdu(read); // refuse -> first -> second refused -> second -> first suppressed -> unwind
EXPECT_EQ(first.not_sent_count_, 1);
EXPECT_EQ(second.not_sent_count_, 1);
}
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(/*clear_sent=*/false);
}
int not_sent_count_{0};
};
} // namespace
// The documented cost of the per-device guard: a clear issued from inside your own on_not_sent()
// resolves your remaining frames silently (like clear_tx_queue_for_device() - you cleared them, you
// know), while other owners sharing the address are still notified.
TEST(ModbusClientHubQueue, SelfClearFromNotSentSilentForClearerNotifiesOthers) {
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.send_pdu(read_a);
clearer.send_pdu(read_b);
bystander.send_pdu(read_c);
ASSERT_EQ(hub.queued_frames(), 3u);
clearer.send_pdu(std::span<const uint8_t>{}); // refused (empty) -> the handler clears the shared address
EXPECT_EQ(clearer.not_sent_count_, 1); // only the refusal; the two swept frames resolve silently
EXPECT_EQ(bystander.not_sent_count_, 1); // the bystander's swept frame is still notified
EXPECT_EQ(hub.queued_frames(), 0u);
}
// The guard must not over-suppress: a sweep started from inside on_not_sent() still delivers its
// victims' notifications (only nested refusals are silenced).
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.send_pdu(read_a);
victim.send_pdu(read_b);
ASSERT_EQ(hub.queued_frames(), 2u);
hub.clear_tx_queue_for_address(0x02, false); // clearer's on_not_sent clears address 0x03 in turn
EXPECT_EQ(clearer.not_sent_count_, 1);
EXPECT_EQ(victim.not_sent_count_, 1); // delivered despite arriving from a nested sweep
EXPECT_EQ(hub.queued_frames(), 0u);
}
namespace {
// tx_blocked() flips to blocked after the first check, so send_next_frame_() passes its own gate but
// send_frame_() refuses - a deterministic transmit failure.
class FlakyBlockHub : public NoResponseProbeHub {
public:
bool tx_blocked() override {
this->tx_blocked_calls_++;
return this->tx_blocked_calls_ > 1;
}
int tx_blocked_calls_{0};
};
// Reacts to a transmit failure by sending another frame from inside the failure callback.
class WriteOnNotSentDevice : public ModbusClientDevice {
public:
WriteOnNotSentDevice(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 write[] = {0x06, 0x00, 0x40, 0x01, 0x02};
this->send_pdu(write);
}
int not_sent_count_{0};
};
} // namespace
// A transmit failure must resolve with the failed frame OUT of the queue before its on_not_sent runs: a
// handler that reacts by sending a new frame must not have that frame discarded by the pop that
// follows - the failed frame is popped first, the new frame survives.
TEST(ModbusClientHubQueue, TransmitFailurePopsBeforeNotify) {
FlakyBlockHub hub;
WriteOnNotSentDevice device(&hub, 0x02);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
device.send_pdu(read);
ASSERT_EQ(hub.queued_frames(), 1u);
hub.send_next_for_test(); // tx_blocked gate passes, send_frame_ refuses -> failure path
EXPECT_EQ(device.not_sent_count_, 1);
ASSERT_EQ(hub.queued_frames(), 1u); // the handler's write survives...
EXPECT_EQ(hub.front().frame.pdu()[0], 0x06); // ...and it is the write, not the failed read
}
// 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.send_pdu(read_a);
device.send_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 send_pdu() from inside on_sent() enqueues behind the in-flight frame rather than sending
// immediately or corrupting the in-flight transaction.
TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
ChainOnSentDevice device(&hub, 0x02);
device.send_pdu(read_pdu());
hub.send_next_for_test(); // first frame goes on the wire -> on_sent chains a follow-up
EXPECT_TRUE(hub.waiting()); // first frame is in flight
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)
}
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.
@@ -336,7 +901,7 @@ namespace {
class NotSentCountingDevice : public ModbusClientDevice {
public:
NotSentCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent() override { this->not_sent_++; }
void on_not_sent(std::span<const uint8_t> request_pdu) override { this->not_sent_++; }
int not_sent_{0};
};
} // namespace