mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
386 lines
17 KiB
C++
386 lines
17 KiB
C++
#include <gtest/gtest.h>
|
|
|
|
#include <cstdint>
|
|
#include <optional>
|
|
#include <span>
|
|
#include <vector>
|
|
|
|
#include "esphome/components/modbus/modbus.h"
|
|
|
|
namespace esphome::modbus::testing {
|
|
|
|
namespace {
|
|
|
|
// Records every typed callback so tests can assert on the dispatch performed by the default
|
|
// on_response()/on_error() implementations.
|
|
class RecordingDevice : public ModbusClientDevice {
|
|
public:
|
|
struct ReadRegistersCall {
|
|
uint16_t start_address;
|
|
std::vector<uint16_t> registers;
|
|
ResponseStatus status;
|
|
};
|
|
struct ReadBitsCall {
|
|
uint16_t start_address;
|
|
uint16_t count;
|
|
std::vector<uint8_t> packed;
|
|
ResponseStatus status;
|
|
};
|
|
struct WriteCall {
|
|
uint16_t address;
|
|
uint16_t value;
|
|
ResponseStatus status;
|
|
};
|
|
|
|
void on_read_holding_registers(uint16_t start_address, std::span<const uint16_t> registers,
|
|
ResponseStatus status) override {
|
|
this->holding_calls.push_back({start_address, {registers.begin(), registers.end()}, status});
|
|
}
|
|
void on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
|
|
ResponseStatus status) override {
|
|
this->input_calls.push_back({start_address, {registers.begin(), registers.end()}, status});
|
|
}
|
|
void on_read_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) override {
|
|
this->coil_calls.push_back({start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status});
|
|
}
|
|
void on_read_discrete_inputs(uint16_t start_address, PackedBits bits, ResponseStatus status) override {
|
|
this->discrete_calls.push_back({start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status});
|
|
}
|
|
void on_write_single_register(uint16_t address, uint16_t value, ResponseStatus status) override {
|
|
this->write_single_register_calls.push_back({address, value, status});
|
|
}
|
|
void on_write_single_coil(uint16_t address, bool value, ResponseStatus status) override {
|
|
this->write_single_coil_calls.push_back({address, static_cast<uint16_t>(value), status});
|
|
}
|
|
void on_write_multiple_registers(uint16_t start_address, std::span<const uint16_t> registers,
|
|
ResponseStatus status) override {
|
|
this->write_multiple_registers_calls.push_back({start_address, {registers.begin(), registers.end()}, status});
|
|
}
|
|
void on_write_multiple_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) override {
|
|
this->write_multiple_coils_calls.push_back(
|
|
{start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status});
|
|
}
|
|
void on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
|
|
ResponseStatus status) override {
|
|
this->custom_requests.emplace_back(request_pdu.begin(), request_pdu.end());
|
|
this->custom_responses.emplace_back(response_pdu.begin(), response_pdu.end());
|
|
this->custom_statuses.push_back(status);
|
|
}
|
|
|
|
std::vector<ReadRegistersCall> holding_calls;
|
|
std::vector<ReadRegistersCall> input_calls;
|
|
std::vector<ReadBitsCall> coil_calls;
|
|
std::vector<ReadBitsCall> discrete_calls;
|
|
std::vector<WriteCall> write_single_register_calls;
|
|
std::vector<WriteCall> write_single_coil_calls;
|
|
std::vector<ReadRegistersCall> write_multiple_registers_calls;
|
|
std::vector<ReadBitsCall> write_multiple_coils_calls;
|
|
std::vector<std::vector<uint8_t>> custom_requests;
|
|
std::vector<std::vector<uint8_t>> custom_responses;
|
|
std::vector<ResponseStatus> custom_statuses;
|
|
};
|
|
|
|
// Overrides only the generic callbacks to verify the typed defaults delegate to them.
|
|
class GenericDevice : public ModbusClientDevice {
|
|
public:
|
|
void on_read_registers(EntityType register_type, uint16_t start_address, std::span<const uint16_t> registers,
|
|
ResponseStatus status) override {
|
|
this->register_type = register_type;
|
|
this->start_address = start_address;
|
|
this->registers.assign(registers.begin(), registers.end());
|
|
this->calls++;
|
|
}
|
|
void on_read_bits(EntityType register_type, uint16_t start_address, PackedBits bits, ResponseStatus status) override {
|
|
this->register_type = register_type;
|
|
this->start_address = start_address;
|
|
this->bit_count = bits.size();
|
|
this->calls++;
|
|
}
|
|
EntityType register_type{EntityType::CUSTOM};
|
|
uint16_t start_address{0};
|
|
uint16_t bit_count{0};
|
|
std::vector<uint16_t> registers;
|
|
int calls{0};
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST(ModbusClientDeviceFanOut, ReadHoldingRegistersSuccess) {
|
|
RecordingDevice device;
|
|
const uint8_t request[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // read 2 regs at 0x100
|
|
const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; // 0x002A, 0x0100
|
|
device.on_response(request, response);
|
|
|
|
ASSERT_EQ(device.holding_calls.size(), 1u);
|
|
const auto &call = device.holding_calls.front();
|
|
EXPECT_EQ(call.start_address, 0x100);
|
|
EXPECT_EQ(call.registers, (std::vector<uint16_t>{0x002A, 0x0100}));
|
|
EXPECT_FALSE(call.status.has_value());
|
|
}
|
|
|
|
TEST(ModbusClientDeviceFanOut, ReadInputRegistersDelegateToGeneric) {
|
|
GenericDevice device;
|
|
const uint8_t request[] = {0x04, 0x00, 0x10, 0x00, 0x01};
|
|
const uint8_t response[] = {0x04, 0x02, 0x12, 0x34};
|
|
device.on_response(request, response);
|
|
|
|
EXPECT_EQ(device.calls, 1);
|
|
EXPECT_EQ(device.register_type, EntityType::INPUT_REGISTER);
|
|
EXPECT_EQ(device.start_address, 0x10);
|
|
EXPECT_EQ(device.registers, (std::vector<uint16_t>{0x1234}));
|
|
}
|
|
|
|
TEST(ModbusClientDeviceFanOut, ReadDiscreteInputsDelegateToGenericBits) {
|
|
GenericDevice device;
|
|
const uint8_t request[] = {0x02, 0x00, 0x20, 0x00, 0x05}; // 5 inputs at 0x20
|
|
const uint8_t response[] = {0x02, 0x01, 0x15};
|
|
device.on_response(request, response);
|
|
|
|
EXPECT_EQ(device.calls, 1);
|
|
EXPECT_EQ(device.register_type, EntityType::DISCRETE_INPUT);
|
|
EXPECT_EQ(device.start_address, 0x20);
|
|
EXPECT_EQ(device.bit_count, 5);
|
|
}
|
|
|
|
// A CRC-valid response whose length does not match its request cannot be decoded per the
|
|
// function-code contract: it goes to the catch-all with the raw PDUs, not to the typed callback.
|
|
TEST(ModbusClientDeviceFanOut, ReadRegistersMismatchedLengthGoesToCatchAll) {
|
|
RecordingDevice device;
|
|
// Request asks for 4 registers but the response only carries 1.
|
|
const uint8_t request[] = {0x03, 0x00, 0x00, 0x00, 0x04};
|
|
const uint8_t response[] = {0x03, 0x02, 0xBE, 0xEF};
|
|
device.on_response(request, response);
|
|
|
|
EXPECT_TRUE(device.holding_calls.empty());
|
|
ASSERT_EQ(device.custom_responses.size(), 1u);
|
|
EXPECT_EQ(device.custom_responses.front(), (std::vector<uint8_t>(response, response + sizeof(response))));
|
|
}
|
|
|
|
// Coil responses are validated the same way: byte count must be ceil(count / 8).
|
|
TEST(ModbusClientDeviceFanOut, ReadCoilsMismatchedLengthGoesToCatchAll) {
|
|
RecordingDevice device;
|
|
const uint8_t request[] = {0x01, 0x00, 0x13, 0x00, 0x13}; // 19 coils -> 3 packed bytes
|
|
const uint8_t response[] = {0x01, 0x02, 0xCD, 0x6B}; // only 2
|
|
device.on_response(request, response);
|
|
|
|
EXPECT_TRUE(device.coil_calls.empty());
|
|
EXPECT_EQ(device.custom_responses.size(), 1u);
|
|
}
|
|
|
|
TEST(ModbusClientDeviceFanOut, ReadCoilsSuccess) {
|
|
RecordingDevice device;
|
|
const uint8_t request[] = {0x01, 0x00, 0x13, 0x00, 0x13}; // 19 coils at 0x13
|
|
const uint8_t response[] = {0x01, 0x03, 0xCD, 0x6B, 0x05};
|
|
device.on_response(request, response);
|
|
|
|
ASSERT_EQ(device.coil_calls.size(), 1u);
|
|
const auto &call = device.coil_calls.front();
|
|
EXPECT_EQ(call.start_address, 0x13);
|
|
EXPECT_EQ(call.count, 19);
|
|
EXPECT_EQ(call.packed, (std::vector<uint8_t>{0xCD, 0x6B, 0x05}));
|
|
EXPECT_FALSE(call.status.has_value());
|
|
// first coil = bit 0 of byte 0
|
|
EXPECT_TRUE(helpers::bit_from_packed(0, call.packed));
|
|
EXPECT_FALSE(helpers::bit_from_packed(1, call.packed));
|
|
}
|
|
|
|
TEST(ModbusClientDeviceFanOut, WriteSingleRegisterSuccess) {
|
|
RecordingDevice device;
|
|
const uint8_t request[] = {0x06, 0x00, 0x01, 0x00, 0x03};
|
|
device.on_response(request, request); // echo
|
|
|
|
ASSERT_EQ(device.write_single_register_calls.size(), 1u);
|
|
const auto &call = device.write_single_register_calls.front();
|
|
EXPECT_EQ(call.address, 1);
|
|
EXPECT_EQ(call.value, 3);
|
|
EXPECT_FALSE(call.status.has_value());
|
|
}
|
|
|
|
TEST(ModbusClientDeviceFanOut, WriteSingleCoilSuccess) {
|
|
RecordingDevice device;
|
|
const uint8_t request[] = {0x05, 0x00, 0xAC, 0xFF, 0x00};
|
|
device.on_response(request, request);
|
|
|
|
ASSERT_EQ(device.write_single_coil_calls.size(), 1u);
|
|
EXPECT_EQ(device.write_single_coil_calls.front().address, 0xAC);
|
|
EXPECT_EQ(device.write_single_coil_calls.front().value, 1u);
|
|
}
|
|
|
|
TEST(ModbusClientDeviceFanOut, WriteErrorReportsRequestArgumentsAndStatus) {
|
|
RecordingDevice device;
|
|
const uint8_t request[] = {0x06, 0x00, 0x01, 0x00, 0x03};
|
|
const uint8_t exception[] = {0x86, 0x02}; // ILLEGAL_DATA_ADDRESS
|
|
device.on_error(request, static_cast<ExceptionCode>(exception[1]));
|
|
|
|
ASSERT_EQ(device.write_single_register_calls.size(), 1u);
|
|
const auto &call = device.write_single_register_calls.front();
|
|
EXPECT_EQ(call.address, 1);
|
|
EXPECT_EQ(call.value, 3);
|
|
EXPECT_EQ(call.status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
|
}
|
|
|
|
TEST(ModbusClientDeviceFanOut, ReadErrorReportsEmptyDataAndStatus) {
|
|
RecordingDevice device;
|
|
const uint8_t request[] = {0x03, 0x01, 0x00, 0x00, 0x02};
|
|
const uint8_t exception[] = {0x83, 0x02};
|
|
device.on_error(request, static_cast<ExceptionCode>(exception[1]));
|
|
|
|
ASSERT_EQ(device.holding_calls.size(), 1u);
|
|
const auto &call = device.holding_calls.front();
|
|
EXPECT_EQ(call.start_address, 0x100);
|
|
EXPECT_TRUE(call.registers.empty());
|
|
EXPECT_EQ(call.status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
|
}
|
|
|
|
TEST(ModbusClientDeviceFanOut, CustomFunctionCodeGoesToCatchAll) {
|
|
RecordingDevice device;
|
|
const uint8_t request[] = {0x47, 0x01, 0x02, 0x03, 0x04};
|
|
const uint8_t response[] = {0x47, 0xAA, 0xBB};
|
|
device.on_response(request, response);
|
|
|
|
ASSERT_EQ(device.custom_requests.size(), 1u);
|
|
EXPECT_EQ(device.custom_requests.front(), (std::vector<uint8_t>{0x47, 0x01, 0x02, 0x03, 0x04}));
|
|
EXPECT_EQ(device.custom_responses.front(), (std::vector<uint8_t>{0x47, 0xAA, 0xBB}));
|
|
EXPECT_FALSE(device.custom_statuses.front().has_value());
|
|
EXPECT_TRUE(device.holding_calls.empty());
|
|
|
|
// On failure the catch-all receives an empty response and the status (the exception code).
|
|
const uint8_t exception[] = {0xC7, 0x02};
|
|
device.on_error(request, static_cast<ExceptionCode>(exception[1]));
|
|
ASSERT_EQ(device.custom_statuses.size(), 2u);
|
|
EXPECT_EQ(device.custom_statuses.back(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
|
EXPECT_TRUE(device.custom_responses.back().empty());
|
|
}
|
|
|
|
// A write ack only echoes the start address and count, so the data that was written is decoded from the
|
|
// request PDU: [0] function code, [1..2] start address, [3..4] count, [5] byte count, [6..] data.
|
|
TEST(ModbusClientDeviceFanOut, WriteMultipleAcksReportStartAndData) {
|
|
RecordingDevice device;
|
|
// Write 2 registers (0x0001, 0x0002) at 0x0020: byte count 4, data from offset 6.
|
|
const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01, 0x00, 0x02};
|
|
const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02};
|
|
device.on_response(reg_request, reg_ack);
|
|
// Write 10 coils at 0x0030: byte count 2, packed bits 0xFF 0x03 from offset 6.
|
|
const uint8_t coil_request[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x02, 0xFF, 0x03};
|
|
const uint8_t coil_ack[] = {0x0F, 0x00, 0x30, 0x00, 0x0A};
|
|
device.on_response(coil_request, coil_ack);
|
|
|
|
ASSERT_EQ(device.write_multiple_registers_calls.size(), 1u);
|
|
EXPECT_EQ(device.write_multiple_registers_calls.front().start_address, 0x20);
|
|
EXPECT_EQ(device.write_multiple_registers_calls.front().registers, (std::vector<uint16_t>{0x0001, 0x0002}));
|
|
ASSERT_EQ(device.write_multiple_coils_calls.size(), 1u);
|
|
EXPECT_EQ(device.write_multiple_coils_calls.front().start_address, 0x30);
|
|
EXPECT_EQ(device.write_multiple_coils_calls.front().count, 10);
|
|
EXPECT_EQ(device.write_multiple_coils_calls.front().packed, (std::vector<uint8_t>{0xFF, 0x03}));
|
|
}
|
|
|
|
// A truncated request (byte-count header promises more data than the PDU carries) is not a standard
|
|
// write-multiple, so it is diverted to on_custom_response() - never clamped and delivered as if complete.
|
|
TEST(ModbusClientDeviceFanOut, WriteMultipleTruncatedRequestDispatchesAsCustom) {
|
|
RecordingDevice device;
|
|
// Header claims 2 registers / 4 data bytes, but only one register's worth is present.
|
|
const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01};
|
|
const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02};
|
|
device.on_response(reg_request, reg_ack);
|
|
|
|
EXPECT_TRUE(device.write_multiple_registers_calls.empty());
|
|
ASSERT_EQ(device.custom_requests.size(), 1u);
|
|
EXPECT_EQ(device.custom_requests.front(), (std::vector<uint8_t>{0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01}));
|
|
}
|
|
|
|
// A request whose byte-count header disagrees with its own quantity field (here: 2 registers but a
|
|
// byte count of 2 instead of 4, with matching data) is non-standard and diverted to the catch-all.
|
|
TEST(ModbusClientDeviceFanOut, WriteMultipleInconsistentByteCountDispatchesAsCustom) {
|
|
RecordingDevice device;
|
|
const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x02, 0x00, 0x01};
|
|
const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02};
|
|
device.on_response(reg_request, reg_ack);
|
|
|
|
EXPECT_TRUE(device.write_multiple_registers_calls.empty());
|
|
EXPECT_EQ(device.custom_requests.size(), 1u);
|
|
}
|
|
|
|
// An exception on a read still dispatches to the typed callback (empty data, status set): the gate must
|
|
// not require a standard response on the failure path, because on_error() delivers an empty response by
|
|
// design.
|
|
TEST(ModbusClientDeviceFanOut, ReadErrorWithEmptyResponseStillDispatchesTyped) {
|
|
RecordingDevice device;
|
|
const uint8_t read_request[] = {0x03, 0x01, 0x00, 0x00, 0x02};
|
|
device.on_error(read_request, ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
|
|
|
ASSERT_EQ(device.holding_calls.size(), 1u);
|
|
EXPECT_TRUE(device.holding_calls.front().registers.empty());
|
|
EXPECT_EQ(device.holding_calls.front().status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
|
EXPECT_TRUE(device.custom_requests.empty());
|
|
}
|
|
|
|
// An error on a coil read must deliver a PackedBits view whose size() is zero - the count must never
|
|
// promise bits that have no bytes behind them (operator[] is unchecked).
|
|
TEST(ModbusClientDeviceFanOut, ReadCoilsErrorDeliversZeroCountBits) {
|
|
RecordingDevice device;
|
|
const uint8_t read_request[] = {0x01, 0x01, 0x00, 0x00, 0x0A};
|
|
device.on_error(read_request, ExceptionCode::SERVICE_DEVICE_FAILURE);
|
|
|
|
ASSERT_EQ(device.coil_calls.size(), 1u);
|
|
EXPECT_EQ(device.coil_calls.front().count, 0);
|
|
EXPECT_TRUE(device.coil_calls.front().packed.empty());
|
|
}
|
|
|
|
// Single-write acks: on success the delivered value is the device's echo (real read-back);
|
|
// on an exception it falls back to the request copy.
|
|
TEST(ModbusTypedDispatch, SingleWriteAckPrefersTheResponseEcho) {
|
|
RecordingDevice device;
|
|
const uint8_t request[] = {0x06, 0x00, 0x10, 0x00, 0x2A};
|
|
const uint8_t echo_clamped[] = {0x06, 0x00, 0x10, 0x00, 0x28}; // device clamped 42 -> 40
|
|
device.on_response(request, echo_clamped);
|
|
ASSERT_EQ(device.write_single_register_calls.size(), 1u);
|
|
EXPECT_EQ(device.write_single_register_calls.front().value, 0x0028); // the echo, not the request
|
|
|
|
device.on_error(request, ExceptionCode::ILLEGAL_DATA_VALUE);
|
|
ASSERT_EQ(device.write_single_register_calls.size(), 2u);
|
|
EXPECT_EQ(device.write_single_register_calls.back().value, 0x002A); // exception: request copy
|
|
}
|
|
|
|
// Deprecated on_modbus_data() compatibility shim (pre-2026.8 API). Records the vectors delivered to
|
|
// the old callback so we can pin its payload framing against the pre-2026.7 behavior.
|
|
#pragma GCC diagnostic push
|
|
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
|
namespace {
|
|
class LegacyDevice : public ModbusDevice {
|
|
public:
|
|
void on_modbus_data(const std::vector<uint8_t> &data) override { this->data_calls.push_back(data); }
|
|
void on_modbus_error(uint8_t function_code, uint8_t exception_code) override {
|
|
this->error_calls.emplace_back(function_code, exception_code);
|
|
}
|
|
std::vector<std::vector<uint8_t>> data_calls;
|
|
std::vector<std::pair<uint8_t, uint8_t>> error_calls;
|
|
};
|
|
} // namespace
|
|
|
|
// Custom (user-defined) function codes historically delivered the payload INCLUDING the function code
|
|
// byte (frame data_offset 1). External components such as the Century VS pump match that first byte
|
|
// against the code they sent, so dropping it (issue #17994) makes every response get ignored.
|
|
TEST(ModbusLegacyShim, CustomFunctionCodeKeepsFunctionCodeByte) {
|
|
LegacyDevice device;
|
|
const uint8_t request[] = {0x45, 0x01, 0x02}; // custom function 0x45
|
|
const uint8_t response[] = {0x45, 0xAA, 0xBB, 0xCC}; // echo of the custom code + data
|
|
device.on_response(request, response);
|
|
|
|
ASSERT_EQ(device.data_calls.size(), 1u);
|
|
EXPECT_EQ(device.data_calls.front(), (std::vector<uint8_t>{0x45, 0xAA, 0xBB, 0xCC}));
|
|
}
|
|
|
|
// Standard reads still strip the function code and byte-count header, matching the pre-2026.7 shim.
|
|
TEST(ModbusLegacyShim, StandardReadStripsHeader) {
|
|
LegacyDevice device;
|
|
const uint8_t request[] = {0x03, 0x01, 0x00, 0x00, 0x02};
|
|
const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
|
|
device.on_response(request, response);
|
|
|
|
ASSERT_EQ(device.data_calls.size(), 1u);
|
|
EXPECT_EQ(device.data_calls.front(), (std::vector<uint8_t>{0x00, 0x2A, 0x01, 0x00}));
|
|
}
|
|
#pragma GCC diagnostic pop
|
|
|
|
} // namespace esphome::modbus::testing
|