#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 protected tx queue and waiting-for-response slot so tests can drive the // no-response path without a UART: force_send_front() mimics send_next_frame_() moving the // front frame in flight, timeout_waiting() mimics the loop() no-response timeout handling. 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(); } // Drives the real unexpected-frame branch in process_modbus_server_frame(). void receive_frame_for_test(uint8_t address, std::span pdu) { this->process_modbus_server_frame(address, pdu); } void timeout_waiting() { if (this->waiting_for_response_.has_value()) this->notify_no_response_(*this->waiting_for_response_); this->waiting_for_response_.reset(); } }; // 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.send_pdu(read_pdu()); ASSERT_EQ(hub.queued_frames(), 1u); hub.force_send_front(); 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.front(); 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.send_pdu(read_pdu()); hub.force_send_front(); 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.send_pdu(read_pdu()); hub.force_send_front(); // 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 retry is re-queued immediately, but the // waiting entry survives as an interrupted shell (device detached) that keeps tx blocked until the // send-wait timeout clears it - without a second no-response callback or a duplicate requeue. TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); device.send_pdu(read_pdu()); hub.force_send_front(); // 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); EXPECT_EQ(device.no_response_count_, 1); ASSERT_EQ(hub.queued_frames(), 1u); // exactly one requeue... EXPECT_EQ(hub.front().device, &device); ASSERT_TRUE(hub.waiting()); // ...while the shell stays in the waiting slot EXPECT_TRUE(hub.waiting_command().interrupted); EXPECT_EQ(hub.waiting_command().device, nullptr); // The send-wait timeout clears the shell without a second callback or another requeue. hub.timeout_waiting(); EXPECT_FALSE(hub.waiting()); EXPECT_EQ(device.no_response_count_, 1); EXPECT_EQ(hub.queued_frames(), 1u); } // 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.send_pdu(read_pdu()); hub.force_send_front(); 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()); } // 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 // 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(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 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_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{}); 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(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(i >> 8), static_cast(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(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 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 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(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 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 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 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(i >> 8), static_cast(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 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(i >> 8), static_cast(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(i >> 8), static_cast(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 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{}); // 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 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. #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 TEST(ModbusClientHubCompat, LegacyCallbackNamesStillForward) { NoResponseProbeHub hub; LegacyNameDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; device.send_pdu(read); hub.force_send_front(); hub.timeout_waiting(); // no reply -> on_no_response -> forwards to on_modbus_no_response EXPECT_EQ(device.legacy_no_response_, 1); device.send_pdu(std::span()); // empty PDU refused -> on_not_sent -> forwards EXPECT_EQ(device.legacy_not_sent_, 1); } // The send_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 and signalled like any other failed send. TEST(ModbusClientHub, OversizedPduIsRefusedWithNotSent) { NoResponseProbeHub hub; LegacyNameDevice device(&hub, 0x02); std::vector big(MAX_PDU_SIZE + 1, 0x41); device.send_pdu(big); EXPECT_EQ(device.legacy_not_sent_, 1); // on_not_sent, observed via the legacy forward EXPECT_TRUE(hub.tx_buffer_empty()); } // --- 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.send_pdu(read_req); hub.force_send_front(); 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.send_pdu(write_req); hub.force_send_front(); 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.send_pdu(read_req); hub.force_send_front(); 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.front().frame.pdu(); EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); hub.force_send_front(); 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.front().frame.pdu()[0], 0x03); hub.force_send_front(); hub.timeout_waiting(); device.read_entities(EntityType::DISCRETE_INPUT, 0x0001, 1); ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.front().frame.pdu()[0], 0x02); hub.force_send_front(); 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() signals on_not_sent() 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, InvalidReadEntitiesSignalsNotSent) { NoResponseProbeHub hub; NotSentCountingDevice device(&hub, 0x02); device.read_entities(EntityType::CUSTOM, 0x0001, 1); EXPECT_EQ(device.not_sent_, 1); EXPECT_EQ(hub.queued_frames(), 0u); } } // namespace esphome::modbus::testing