mirror of
https://github.com/esphome/esphome.git
synced 2026-07-11 09:25:34 +00:00
66ab807596
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
286 lines
10 KiB
C++
286 lines
10 KiB
C++
#include <gtest/gtest.h>
|
|
|
|
#include "esphome/components/modbus_server/modbus_server.h"
|
|
|
|
namespace esphome::modbus_server {
|
|
|
|
using modbus::ModbusExceptionCode;
|
|
using modbus::RegisterValues;
|
|
|
|
namespace {
|
|
|
|
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
|
|
RegisterValues registers;
|
|
for (uint16_t value : values)
|
|
registers.push_back(value);
|
|
return registers;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// A single writable WORD register is applied and the handler reports success (nullopt).
|
|
TEST(ModbusServerWrite, SingleWordSucceeds) {
|
|
ModbusServer server;
|
|
int64_t written = -1;
|
|
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
|
|
reg.write_lambda = [&written](int64_t value) {
|
|
written = value;
|
|
return true;
|
|
};
|
|
server.add_server_register(®);
|
|
|
|
auto status = server.on_write_registers(0x0000, make_registers({0x1234}));
|
|
EXPECT_FALSE(status.has_value()); // nullopt == success
|
|
EXPECT_EQ(written, 0x1234);
|
|
}
|
|
|
|
// A multi-register value is decoded high word first and applied as a single number.
|
|
TEST(ModbusServerWrite, DwordSucceeds) {
|
|
ModbusServer server;
|
|
int64_t written = -1;
|
|
ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2);
|
|
reg.write_lambda = [&written](int64_t value) {
|
|
written = value;
|
|
return true;
|
|
};
|
|
server.add_server_register(®);
|
|
|
|
auto status = server.on_write_registers(0x0000, make_registers({0x1234, 0x5678}));
|
|
EXPECT_FALSE(status.has_value());
|
|
EXPECT_EQ(written, 0x12345678);
|
|
}
|
|
|
|
// Regression: a request that under-supplies a multi-register value is rejected before any
|
|
// write_lambda runs, so no register is partially written.
|
|
TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) {
|
|
ModbusServer server;
|
|
bool word_written = false;
|
|
ServerRegister word_reg(0x0000, SensorValueType::U_WORD, 1);
|
|
word_reg.write_lambda = [&word_written](int64_t) {
|
|
word_written = true;
|
|
return true;
|
|
};
|
|
bool dword_written = false;
|
|
ServerRegister dword_reg(0x0001, SensorValueType::U_DWORD, 2); // needs two registers
|
|
dword_reg.write_lambda = [&dword_written](int64_t) {
|
|
dword_written = true;
|
|
return true;
|
|
};
|
|
server.add_server_register(&word_reg);
|
|
server.add_server_register(&dword_reg);
|
|
|
|
// Two words supplied: one for the WORD at 0x0000, but only one of the two the DWORD at 0x0001 needs.
|
|
auto status = server.on_write_registers(0x0000, make_registers({0x1111, 0x2222}));
|
|
ASSERT_TRUE(status.has_value());
|
|
if (status.has_value())
|
|
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_VALUE);
|
|
EXPECT_FALSE(word_written); // the writable WORD must NOT have been applied
|
|
EXPECT_FALSE(dword_written);
|
|
}
|
|
|
|
// A read-only register (no write_lambda) yields ILLEGAL_DATA_ADDRESS and applies nothing.
|
|
TEST(ModbusServerWrite, UnwritableRegisterRejected) {
|
|
ModbusServer server;
|
|
ServerRegister read_only(0x0000, SensorValueType::U_WORD, 1); // no write_lambda set
|
|
server.add_server_register(&read_only);
|
|
|
|
auto status = server.on_write_registers(0x0000, make_registers({0x1234}));
|
|
ASSERT_TRUE(status.has_value());
|
|
if (status.has_value())
|
|
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
|
|
}
|
|
|
|
// An address with no registered register yields ILLEGAL_DATA_ADDRESS.
|
|
TEST(ModbusServerWrite, UnmatchedAddressRejected) {
|
|
ModbusServer server;
|
|
auto status = server.on_write_registers(0x0005, make_registers({0x1234}));
|
|
ASSERT_TRUE(status.has_value());
|
|
if (status.has_value())
|
|
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
|
|
}
|
|
|
|
// A write_lambda failing at runtime is the one non-atomic case: the earlier register is already
|
|
// applied, and the handler reports SERVICE_DEVICE_FAILURE.
|
|
TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) {
|
|
ModbusServer server;
|
|
bool first_written = false;
|
|
ServerRegister first(0x0000, SensorValueType::U_WORD, 1);
|
|
first.write_lambda = [&first_written](int64_t) {
|
|
first_written = true;
|
|
return true;
|
|
};
|
|
ServerRegister second(0x0001, SensorValueType::U_WORD, 1);
|
|
second.write_lambda = [](int64_t) { return false; }; // rejects at runtime
|
|
server.add_server_register(&first);
|
|
server.add_server_register(&second);
|
|
|
|
auto status = server.on_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB}));
|
|
ASSERT_TRUE(status.has_value());
|
|
if (status.has_value())
|
|
EXPECT_EQ(status.value(), ModbusExceptionCode::SERVICE_DEVICE_FAILURE);
|
|
EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure
|
|
}
|
|
|
|
// --- on_read_registers --------------------------------------------------
|
|
|
|
TEST(ModbusServerRead, SingleWordSucceeds) {
|
|
ModbusServer server;
|
|
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
|
|
reg.read_lambda = []() -> int64_t { return 0x1234; };
|
|
server.add_server_register(®);
|
|
|
|
RegisterValues out;
|
|
auto status = server.on_read_registers(0x0000, 1, out);
|
|
EXPECT_FALSE(status.has_value());
|
|
ASSERT_EQ(out.size(), 1u);
|
|
EXPECT_EQ(out[0], 0x1234);
|
|
}
|
|
|
|
TEST(ModbusServerRead, DwordReturnsTwoWordsHighFirst) {
|
|
ModbusServer server;
|
|
ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2);
|
|
reg.read_lambda = []() -> int64_t { return 0x12345678; };
|
|
server.add_server_register(®);
|
|
|
|
RegisterValues out;
|
|
auto status = server.on_read_registers(0x0000, 2, out);
|
|
EXPECT_FALSE(status.has_value());
|
|
ASSERT_EQ(out.size(), 2u);
|
|
EXPECT_EQ(out[0], 0x1234);
|
|
EXPECT_EQ(out[1], 0x5678);
|
|
}
|
|
|
|
// Starting inside a multi-register value is rejected with ILLEGAL_DATA_ADDRESS -- not masked by the courtesy
|
|
// default -- and the read_lambda is never invoked.
|
|
TEST(ModbusServerRead, StartInsideValueRejected) {
|
|
ModbusServer server;
|
|
bool read_called = false;
|
|
ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2); // occupies 0x0010 and 0x0011
|
|
reg.read_lambda = [&read_called]() -> int64_t {
|
|
read_called = true;
|
|
return 0;
|
|
};
|
|
server.set_server_courtesy_response(
|
|
ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD});
|
|
server.add_server_register(®);
|
|
|
|
RegisterValues out;
|
|
auto status = server.on_read_registers(0x0011, 1, out); // the second cell of the DWORD
|
|
ASSERT_TRUE(status.has_value());
|
|
if (status.has_value())
|
|
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
|
|
EXPECT_FALSE(read_called);
|
|
}
|
|
|
|
// A read that stops short of a value's end clips it -> ILLEGAL_DATA_ADDRESS, and the read_lambda is not invoked.
|
|
TEST(ModbusServerRead, ClippedTailRejected) {
|
|
ModbusServer server;
|
|
bool read_called = false;
|
|
ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2);
|
|
reg.read_lambda = [&read_called]() -> int64_t {
|
|
read_called = true;
|
|
return 0;
|
|
};
|
|
server.add_server_register(®);
|
|
|
|
RegisterValues out;
|
|
auto status = server.on_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers
|
|
ASSERT_TRUE(status.has_value());
|
|
if (status.has_value())
|
|
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
|
|
EXPECT_FALSE(read_called);
|
|
}
|
|
|
|
// A write-only register (no read_lambda) is not readable -> ILLEGAL_DATA_ADDRESS, not a courtesy default.
|
|
TEST(ModbusServerRead, WriteOnlyRegisterRejected) {
|
|
ModbusServer server;
|
|
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); // no read_lambda set
|
|
server.set_server_courtesy_response(
|
|
ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD});
|
|
server.add_server_register(®);
|
|
|
|
RegisterValues out;
|
|
auto status = server.on_read_registers(0x0000, 1, out);
|
|
ASSERT_TRUE(status.has_value());
|
|
if (status.has_value())
|
|
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
|
|
}
|
|
|
|
// An unregistered address with courtesy enabled returns the default value for each cell.
|
|
TEST(ModbusServerRead, CourtesyDefaultForUnregistered) {
|
|
ModbusServer server;
|
|
server.set_server_courtesy_response(
|
|
ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD});
|
|
|
|
RegisterValues out;
|
|
auto status = server.on_read_registers(0x0005, 2, out);
|
|
EXPECT_FALSE(status.has_value());
|
|
ASSERT_EQ(out.size(), 2u);
|
|
EXPECT_EQ(out[0], 0xABCD);
|
|
EXPECT_EQ(out[1], 0xABCD);
|
|
}
|
|
|
|
// An unregistered address with courtesy disabled is rejected.
|
|
TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) {
|
|
ModbusServer server;
|
|
RegisterValues out;
|
|
auto status = server.on_read_registers(0x0005, 1, out);
|
|
ASSERT_TRUE(status.has_value());
|
|
if (status.has_value())
|
|
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
|
|
}
|
|
|
|
// --- partial reads (opt-in) ----------------------------------------------------
|
|
|
|
// With allow_partial_read, reading only the first register of a DWORD returns its high word.
|
|
TEST(ModbusServerRead, PartialReadHighWord) {
|
|
ModbusServer server;
|
|
ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2);
|
|
reg.allow_partial_read = true;
|
|
reg.read_lambda = []() -> int64_t { return 0x12345678; };
|
|
server.add_server_register(®);
|
|
|
|
RegisterValues out;
|
|
auto status = server.on_read_registers(0x0010, 1, out);
|
|
EXPECT_FALSE(status.has_value());
|
|
ASSERT_EQ(out.size(), 1u);
|
|
EXPECT_EQ(out[0], 0x1234);
|
|
}
|
|
|
|
// With allow_partial_read, starting at the interior cell returns the low word.
|
|
TEST(ModbusServerRead, PartialReadLowWordFromInterior) {
|
|
ModbusServer server;
|
|
ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2);
|
|
reg.allow_partial_read = true;
|
|
reg.read_lambda = []() -> int64_t { return 0x12345678; };
|
|
server.add_server_register(®);
|
|
|
|
RegisterValues out;
|
|
auto status = server.on_read_registers(0x0011, 1, out);
|
|
EXPECT_FALSE(status.has_value());
|
|
ASSERT_EQ(out.size(), 1u);
|
|
EXPECT_EQ(out[0], 0x5678);
|
|
}
|
|
|
|
// Slicing is in wire order, so a reversed value type partials correctly: U_DWORD_R emits the low word
|
|
// first, so 0x0010 holds 0x5678 and 0x0011 holds 0x1234.
|
|
TEST(ModbusServerRead, PartialReadReversedType) {
|
|
ModbusServer server;
|
|
ServerRegister reg(0x0010, SensorValueType::U_DWORD_R, 2);
|
|
reg.allow_partial_read = true;
|
|
reg.read_lambda = []() -> int64_t { return 0x12345678; };
|
|
server.add_server_register(®);
|
|
|
|
RegisterValues first;
|
|
ASSERT_FALSE(server.on_read_registers(0x0010, 1, first).has_value());
|
|
ASSERT_EQ(first.size(), 1u);
|
|
EXPECT_EQ(first[0], 0x5678);
|
|
|
|
RegisterValues second;
|
|
ASSERT_FALSE(server.on_read_registers(0x0011, 1, second).has_value());
|
|
ASSERT_EQ(second.size(), 1u);
|
|
EXPECT_EQ(second[0], 0x1234);
|
|
}
|
|
|
|
} // namespace esphome::modbus_server
|