mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
[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:
co-authored by
Claude Fable 5
parent
56028d0932
commit
19511f5787
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user