#include #include #include #include #include #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(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 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 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 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 read_pdu() { StaticVector 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).options.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).options.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).options.continuous); hub.force_send_next(); hub.timeout_waiting(); // no response -> device requests retry ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_TRUE(hub.queued(0).options.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).options.continuous); device.read_holding_registers(0x100, 2); // one-shot duplicate downgrades the poll ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_FALSE(hub.queued(0).options.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().options.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 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).options.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).options.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).options.continuous); device.read_holding_registers(0x100, 2, {.continuous = true}); ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_TRUE(hub.queued(0).options.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).options.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().options.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).options.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).options.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 request_pdu) override { this->sent_count_++; this->last_sent_pdu_.assign(request_pdu.begin(), request_pdu.end()); } void on_not_sent(std::span 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 last_sent_pdu_; std::vector 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(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 request_pdu) override { this->sent_count_++; } void on_response(std::span request_pdu, std::span response_pdu) override { this->response_count_++; this->last_response_size_ = response_pdu.size(); } bool on_no_response(std::span 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 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 request_pdu, std::span 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 request_pdu, std::span response_pdu) override { this->data_count_++; } void on_error(std::span request_pdu, ExceptionCode exception_code) override { this->error_count_++; } bool on_no_response(std::span 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 request_pdu) override { this->not_sent_count_++; this->last_not_sent_pdu_.assign(request_pdu.begin(), request_pdu.end()); } void on_sent(std::span 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 last_not_sent_pdu_; std::vector 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 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 request_pdu, std::span 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(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(i >> 8), static_cast(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 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 request_pdu, std::span 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 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(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 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 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 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(i >> 8), static_cast(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 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{})); // 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 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 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 request_pdu, std::span 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()); 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())); // 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 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 &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 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 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 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 &expected) { ASSERT_EQ(hub.queued_frames(), 1u); auto pdu = hub.queued(0).frame.pdu(); EXPECT_EQ(std::vector(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 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 request_pdu, std::span response_pdu) override { this->queue_pdu(std::vector(request_pdu.begin(), request_pdu.end())); } void queue_pdu(const std::vector &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).options.continuous); // the one-shot re-send downgraded the poll } // An exception-flagged function code is never silently re-sendable, even though the read check // masks the exception bit: its duplicate takes the drop path like any other non-read. TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) { 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 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 request_pdu) override { this->no_response_count_++; this->clear_tx_queue_for_address(); return false; // gave up } void on_not_sent(std::span 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