[modbus_server] Add coil/discrete-input support (#17464)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bonne Eggleston
2026-08-10 18:13:12 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent c8d2c3691a
commit 2a0f2d59f0
11 changed files with 632 additions and 22 deletions
@@ -15,6 +15,16 @@ modbus_server:
- id: modbus_server3
address: 0x3
modbus_id: mod_bus2
bits:
- address: 0x0
read_lambda: |-
return true;
- address: 0x1
read_lambda: |-
return address == 0x1;
write_lambda: |-
printf("bit address=%d, value=%d\n", (int) address, (int) x);
return true;
registers:
- address: 0x9
value_type: S_DWORD
@@ -105,15 +105,29 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) {
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// An address with no registered register yields ILLEGAL_DATA_ADDRESS.
// A write to an address not covered by any configured register (on a populated server) yields
// ILLEGAL_DATA_ADDRESS.
TEST(ModbusServerWrite, UnmatchedAddressRejected) {
ModbusServer server;
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
reg.write_lambda = [](int64_t) { return true; };
server.add_server_register(&reg);
auto status = server.on_write_registers(0x0005, make_registers({0x1234}));
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// A server with no registers configured does not implement the register-write function: ILLEGAL_FUNCTION.
TEST(ModbusServerWrite, EmptyServerRejectsWithIllegalFunction) {
ModbusServer server;
auto status = server.on_write_registers(0x0000, make_registers({0x1234}));
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION);
}
// 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) {
@@ -248,9 +262,13 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) {
EXPECT_EQ(out[1], 0xABCD);
}
// An unregistered address with courtesy disabled is rejected.
// An unregistered address on a populated server (courtesy disabled) is rejected with ILLEGAL_DATA_ADDRESS.
TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) {
ModbusServer server;
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
reg.read_lambda = []() -> int64_t { return 0x1234; };
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_read_registers(0x0005, 1, out);
ASSERT_TRUE(status.has_value());
@@ -258,6 +276,31 @@ TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) {
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// A server with no registers configured (courtesy disabled) does not implement the register-read
// function: ILLEGAL_FUNCTION.
TEST(ModbusServerRead, EmptyServerRejectsWithIllegalFunction) {
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(), ExceptionCode::ILLEGAL_FUNCTION);
}
// A register read lambda returning an empty optional declines the read: the whole request is
// answered with SERVICE_DEVICE_FAILURE. Uses set_read_lambda<T> so the optional-forwarding wrapper
// (not a hand-assigned read_lambda) is what carries the decline through.
TEST(ModbusServerRead, ReadLambdaDecliningIsServiceDeviceFailure) {
ModbusServer server;
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
reg.set_read_lambda<uint16_t>([](uint16_t address) -> optional<uint16_t> { return {}; });
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_read_registers(0x0000, 1, out);
EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE);
}
// --- partial reads (opt-in) ----------------------------------------------------
// With allow_partial_read, reading only the first register of a DWORD returns its high word.
@@ -310,4 +353,139 @@ TEST(ModbusServerRead, PartialReadReversedType) {
EXPECT_EQ(second[0], 0x1234);
}
// --- bits (coils / discrete inputs, one shared address space) -------------------
// Bits are read through the shared table regardless of which read function code arrived:
// the hub routes both 0x01 and 0x02 to on_read_bits().
TEST(ModbusServerBits, ReadSetsRequestedBits) {
ModbusServer server;
ServerBit bit0(0x0000);
bit0.set_read_lambda([](uint16_t) { return true; });
ServerBit bit1(0x0001);
bit1.set_read_lambda([](uint16_t) { return false; });
ServerBit bit2(0x0002);
bit2.set_read_lambda([](uint16_t) { return true; });
server.add_server_bit(&bit0);
server.add_server_bit(&bit1);
server.add_server_bit(&bit2);
uint8_t packed[1] = {0};
auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 3));
EXPECT_FALSE(status.has_value());
EXPECT_EQ(packed[0], 0b101);
}
// The read lambda receives the bit's address, so one lambda can serve several bits.
TEST(ModbusServerBits, ReadLambdaReceivesAddress) {
ModbusServer server;
ServerBit server_bit(0x0007);
server_bit.set_read_lambda([](uint16_t address) { return address == 0x0007; });
server.add_server_bit(&server_bit);
uint8_t packed[1] = {0};
auto status = server.on_read_bits(0x0007, modbus::MutablePackedBits(packed, 1));
EXPECT_FALSE(status.has_value());
EXPECT_EQ(packed[0], 0x01);
}
// An unregistered or write-only bit rejects the whole read with ILLEGAL_DATA_ADDRESS.
TEST(ModbusServerBits, UnreadableBitRejectsRead) {
ModbusServer server;
ServerBit readable(0x0000);
readable.set_read_lambda([](uint16_t) { return true; });
ServerBit write_only(0x0001);
write_only.set_write_lambda([](uint16_t, bool) { return true; });
server.add_server_bit(&readable);
server.add_server_bit(&write_only);
uint8_t packed[1] = {0};
auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2));
EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
auto unregistered = server.on_read_bits(0x0005, modbus::MutablePackedBits(packed, 1));
EXPECT_EQ(unregistered, ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// A read lambda returning an empty optional declines the read: the whole request is answered
// with SERVICE_DEVICE_FAILURE.
TEST(ModbusServerBits, ReadLambdaDecliningIsServiceDeviceFailure) {
ModbusServer server;
ServerBit ok(0x0000);
ok.set_read_lambda([](uint16_t) { return true; });
ServerBit declining(0x0001);
declining.set_read_lambda([](uint16_t) -> optional<bool> { return {}; });
server.add_server_bit(&ok);
server.add_server_bit(&declining);
uint8_t packed[1] = {0};
auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2));
EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE);
}
// A multi-coil write applies every bit and reports success.
TEST(ModbusServerBits, WriteAppliesAllBits) {
ModbusServer server;
bool state[2] = {false, true};
ServerBit bit0(0x0000);
bit0.set_write_lambda([&state](uint16_t, bool value) {
state[0] = value;
return true;
});
ServerBit bit1(0x0001);
bit1.set_write_lambda([&state](uint16_t, bool value) {
state[1] = value;
return true;
});
server.add_server_bit(&bit0);
server.add_server_bit(&bit1);
const uint8_t packed[1] = {0b01}; // bit0 on, bit1 off
auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2));
EXPECT_FALSE(status.has_value());
EXPECT_TRUE(state[0]);
EXPECT_FALSE(state[1]);
}
// Pre-flight atomicity: an unwritable bit anywhere in the span rejects the write before any
// bit is applied.
TEST(ModbusServerBits, UnwritableBitAppliesNothing) {
ModbusServer server;
bool written = false;
ServerBit writable(0x0000);
writable.set_write_lambda([&written](uint16_t, bool) {
written = true;
return true;
});
ServerBit read_only(0x0001);
read_only.set_read_lambda([](uint16_t) { return false; });
server.add_server_bit(&writable);
server.add_server_bit(&read_only);
const uint8_t packed[1] = {0b11};
auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2));
EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_FALSE(written); // the writable bit must NOT have been applied
}
// A write lambda failing at runtime is the one non-atomic case: earlier bits stay applied and
// the handler reports SERVICE_DEVICE_FAILURE (mirrors the register behavior).
TEST(ModbusServerBits, CallbackFailureIsServiceDeviceFailure) {
ModbusServer server;
bool first_written = false;
ServerBit first(0x0000);
first.set_write_lambda([&first_written](uint16_t, bool) {
first_written = true;
return true;
});
ServerBit second(0x0001);
second.set_write_lambda([](uint16_t, bool) { return false; }); // rejects at runtime
server.add_server_bit(&first);
server.add_server_bit(&second);
const uint8_t packed[1] = {0b11};
auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2));
EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE);
EXPECT_TRUE(first_written);
}
} // namespace esphome::modbus_server