mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
353 lines
13 KiB
C++
353 lines
13 KiB
C++
#include <gtest/gtest.h>
|
|
|
|
#include <cstdint>
|
|
#include <span>
|
|
|
|
#include "esphome/components/modbus/modbus.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(); }
|
|
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 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<const uint8_t> 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() 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() override {
|
|
this->no_response_count_++;
|
|
this->clear_tx_queue_for_device(); // detaches this device from the waiting slot mid-callback
|
|
return true; // and still requests a retry
|
|
}
|
|
int no_response_count_{0};
|
|
};
|
|
|
|
constexpr uint8_t READ_PDU[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // read 2 holding registers at 0x100
|
|
|
|
StaticVector<uint8_t, MAX_PDU_SIZE> read_pdu() {
|
|
StaticVector<uint8_t, MAX_PDU_SIZE> pdu;
|
|
pdu.assign(READ_PDU, READ_PDU + sizeof(READ_PDU));
|
|
return pdu;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// A device that requests a retry gets the frame the hub was holding re-queued on its behalf,
|
|
// byte-identical and still routed to the same device.
|
|
TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) {
|
|
NoResponseProbeHub hub;
|
|
RetryingDevice device(&hub, 0x02, /*retry=*/true);
|
|
|
|
device.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());
|
|
}
|
|
|
|
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<const uint8_t>()); // 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<uint8_t> 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<uint8_t> &data) override { this->last_data_ = data; }
|
|
void on_modbus_error(uint8_t function_code, uint8_t exception_code) override {
|
|
this->last_error_fc_ = function_code;
|
|
this->last_error_code_ = exception_code;
|
|
}
|
|
std::vector<uint8_t> last_data_;
|
|
int last_error_fc_{-1};
|
|
int last_error_code_{-1};
|
|
};
|
|
#pragma GCC diagnostic pop
|
|
} // namespace
|
|
|
|
TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) {
|
|
NoResponseProbeHub hub;
|
|
LegacyApiDevice device(&hub, 0x02);
|
|
|
|
// Read response: on_modbus_data() historically received the payload after the function code and
|
|
// the byte-count byte, as an owning vector.
|
|
const uint8_t read_req[] = {0x03, 0x00, 0x10, 0x00, 0x02};
|
|
device.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<uint8_t> expected{0x00, 0x2A, 0x01, 0x00};
|
|
EXPECT_EQ(device.last_data_, expected);
|
|
|
|
// Write echo: no byte-count byte, so the payload is everything after the function code.
|
|
const uint8_t write_req[] = {0x06, 0x00, 0x10, 0x00, 0x2A};
|
|
device.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<uint8_t> expected_echo{0x00, 0x10, 0x00, 0x2A};
|
|
EXPECT_EQ(device.last_data_, expected_echo);
|
|
|
|
// Exception response: on_modbus_error() received the masked function code and the exception code.
|
|
device.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<uint8_t> &expected) {
|
|
ASSERT_EQ(hub.queued_frames(), 1u);
|
|
auto pdu = hub.front().frame.pdu();
|
|
EXPECT_EQ(std::vector<uint8_t>(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() 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
|