[modbus_client] Add read/write multiple registers (FC 0x17) (#18215)

This commit is contained in:
Bonne Eggleston
2026-08-10 15:18:48 -05:00
committed by GitHub
parent 8a16ead8ce
commit 596827c51c
12 changed files with 520 additions and 52 deletions
@@ -118,6 +118,35 @@ TEST(ModbusClientDeviceFanOut, ReadHoldingRegistersSuccess) {
EXPECT_FALSE(call.status.has_value());
}
// FC 0x17: the response carries only the read block, so it decodes as a holding-register read of the read
// start/count. The write half has no client-side ack callback - it is confirmed by a successful response.
TEST(ModbusClientDeviceFanOut, ReadWriteMultipleRegistersDeliversReadBlockAsHolding) {
RecordingDevice device;
// read 2 regs at 0x0010, write 1 reg (0x00FF) at 0x0020
const uint8_t request[] = {0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0xFF};
const uint8_t response[] = {0x17, 0x04, 0x00, 0x2A, 0x01, 0x00}; // read-back: 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, 0x0010); // the READ start address, not the write
EXPECT_EQ(call.registers, (std::vector<uint16_t>{0x002A, 0x0100}));
EXPECT_FALSE(call.status.has_value());
EXPECT_TRUE(device.write_multiple_registers_calls.empty()); // no separate write-ack on the client side
}
// A 0x17 response shorter than the requested read count is self-consistent but wrong; it must be diverted
// to on_custom_response(), never clamped and delivered as if complete.
TEST(ModbusClientDeviceFanOut, ReadWriteMultipleRegistersShortResponseGoesToCustom) {
RecordingDevice device;
const uint8_t request[] = {0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0xFF};
const uint8_t response[] = {0x17, 0x02, 0x00, 0x2A}; // only 1 register, but 2 were requested
device.on_response(request, response);
EXPECT_TRUE(device.holding_calls.empty());
EXPECT_EQ(device.custom_requests.size(), 1u);
}
TEST(ModbusClientDeviceFanOut, ReadInputRegistersDelegateToGeneric) {
GenericDevice device;
const uint8_t request[] = {0x04, 0x00, 0x10, 0x00, 0x01};
@@ -483,6 +483,71 @@ TEST(ModbusTypedBuilders, WriteRegistersPduRejectsOverLimit) {
EXPECT_FALSE(create_write_registers_pdu(0x0000, values).empty());
}
TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduWireBytes) {
const uint16_t write_values[] = {0x000B, 0x0016};
// Read 2 registers at 0x0010, write 2 registers at 0x0020.
auto pdu = create_read_write_multiple_registers_pdu(0x0010, 2, 0x0020, write_values);
const std::vector<uint8_t> expected{0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20,
0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size()));
}
TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduRejectsOutOfRange) {
const uint16_t one_value[] = {0x0001};
const uint16_t two_values[] = {0x0001, 0x0002};
// Read count out of range (zero and above the read ceiling).
EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 0, 0x0020, one_value).empty());
EXPECT_TRUE(
create_read_write_multiple_registers_pdu(0x0000, MAX_NUM_OF_REGISTERS_TO_READ + 1, 0x0020, one_value).empty());
// Write count out of range (empty, and above the read/write ceiling which is lower than a plain write).
EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 1, 0x0020, std::span<const uint16_t>()).empty());
std::vector<uint16_t> too_many(MAX_NUM_OF_REGISTERS_TO_WRITE_RW + 1, 0xAAAA);
EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 1, 0x0020, too_many).empty());
// Both blocks at their respective ceilings are accepted.
std::vector<uint16_t> at_write_limit(MAX_NUM_OF_REGISTERS_TO_WRITE_RW, 0xAAAA);
EXPECT_FALSE(
create_read_write_multiple_registers_pdu(0x0000, MAX_NUM_OF_REGISTERS_TO_READ, 0x0020, at_write_limit).empty());
// A block that runs past the 16-bit address space is refused (read block, then write block).
EXPECT_TRUE(create_read_write_multiple_registers_pdu(0xFFFF, 2, 0x0020, one_value).empty());
EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 2, 0xFFFF, two_values).empty());
// Accept boundary: a block ending exactly at 0x10000 (last register 0xFFFF) still fits.
EXPECT_FALSE(create_read_write_multiple_registers_pdu(0xFFFE, 2, 0x0000, one_value).empty()); // read ends at 0x10000
EXPECT_FALSE(
create_read_write_multiple_registers_pdu(0x0000, 1, 0xFFFF, one_value).empty()); // write ends at 0x10000
}
TEST(ModbusFunctionCodeClass, ReadWriteMultipleCountsAsBothReadAndWrite) {
const auto rw = static_cast<uint8_t>(FC::READ_WRITE_MULTIPLE_REGISTERS);
// 0x17 both reads and writes, but it is not a pure (retry-safe) read.
EXPECT_TRUE(is_function_code_read(rw));
EXPECT_TRUE(is_function_code_write(rw));
EXPECT_FALSE(is_function_code_read_only(rw));
// Pure reads are read and read-only, never write.
const auto rd = static_cast<uint8_t>(FC::READ_HOLDING_REGISTERS);
EXPECT_TRUE(is_function_code_read(rd));
EXPECT_TRUE(is_function_code_read_only(rd));
EXPECT_FALSE(is_function_code_write(rd));
// Plain writes are write only.
const auto wr = static_cast<uint8_t>(FC::WRITE_MULTIPLE_REGISTERS);
EXPECT_TRUE(is_function_code_write(wr));
EXPECT_FALSE(is_function_code_read(wr));
EXPECT_FALSE(is_function_code_read_only(wr));
// Mask-write register mutates via read-modify-write, so it classes as a write, never a read.
const auto mask = static_cast<uint8_t>(FC::MASK_WRITE_REGISTER);
EXPECT_TRUE(is_function_code_write(mask));
EXPECT_FALSE(is_function_code_read(mask));
EXPECT_FALSE(is_function_code_read_only(mask));
}
TEST(ModbusCreateClientPdu, ReadWriteMultipleReturnsEmpty) {
// The generic builder cannot express 0x17's two blocks; callers use the dedicated builder instead.
const uint16_t values[] = {0x0001};
EXPECT_TRUE(create_client_pdu(FC::READ_WRITE_MULTIPLE_REGISTERS, 0x0000, 1, reinterpret_cast<const uint8_t *>(values),
sizeof(values))
.empty());
}
TEST(ModbusTypedBuilders, FloatToPayloadAppendsToExistingContent) {
// The container overload appends - the semantic every migrated caller relies on when a lambda
// has already put words into the buffer.
@@ -35,6 +35,8 @@ button:
id(bare_client).write_single_register(0x10, 42);
id(bare_client).write_single_coil(0x01, true);
id(bare_client_explicit_hub).read_holding_registers(0x20, 4);
const uint16_t rw_vals[] = {1, 2};
id(bare_client).read_write_multiple_registers(0x0400, 2, 0x0300, rw_vals);
- platform: template
name: "Send Read"
on_press:
@@ -134,3 +136,13 @@ button:
on_error:
then:
- lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);'
- modbus_client.read_write_multiple_registers:
address: 0x01
write_address: 0x0300
values: !lambda "return {1, 2};"
read_address: 0x0400
read_count: 2
on_response:
then:
# `values` here is the READ-BACK block, not the written block above
- lambda: 'ESP_LOGI("modbus_client.test", "rw read0=%u n=%u", values[0], (unsigned) values.size());'
@@ -0,0 +1,111 @@
esphome:
name: uart-mock-modbus-cli-rw
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
# Two virtual buses looped back to each other: the client's transmissions reach the server and the
# server's replies reach the client. auto_start so forwarding is active before the button fires.
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_client
data: !lambda return data;
- id: virtual_uart_client
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: stored_1
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_client
id: virtual_modbus_client
role: client
turnaround_time: 10ms
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
registers:
# Writable + readable register: the read publishes what it returns, so the test can confirm the
# write half of the 0x17 ran before the read half (Modbus 6.17).
- address: 0x01
value_type: U_WORD
read_lambda: |-
id(srv_read_1).publish_state(id(stored_1));
return id(stored_1);
write_lambda: |-
id(stored_1) = x;
id(srv_write_1).publish_state(x);
return true;
# Read-only register, returned together with 0x01 by the 2-register read half.
- address: 0x02
value_type: U_WORD
read_lambda: return 0x00AA;
sensor:
# Server-side observations.
- platform: template
name: "srv_write_1"
id: srv_write_1
- platform: template
name: "srv_read_1"
id: srv_read_1
# Client-side read-back: the values the client's on_response received.
- platform: template
name: "client_read_0"
id: client_read_0
- platform: template
name: "client_read_1"
id: client_read_1
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
# FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction.
- modbus_client.read_write_multiple_registers:
address: 0x01
read_address: 0x0001
read_count: 2
write_address: 0x0001
values: [0x1234]
on_response:
then:
- lambda: |-
// values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002.
if (values.size() >= 2) {
id(client_read_0).publish_state(values[0]);
id(client_read_1).publish_state(values[1]);
}
@@ -756,3 +756,38 @@ async def test_uart_mock_modbus_fairness(
f"controllers did not get a fair share of the bus: "
f"controller 1 issued {count_1}, controller 2 issued {count_2}"
)
@pytest.mark.asyncio
async def test_uart_mock_modbus_client_read_write(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A modbus_client.read_write_multiple_registers action (FC 0x17) drives a server end to end.
The client writes reg 0x0001 = 0x1234 and reads regs 0x0001..0x0002 in one transaction; the server
applies the write first (Modbus 6.17). The test confirms both ends: the server's write_lambda ran
(srv_write_1) and the read half came back to the client's on_response (client_read_0 = the
just-written 0x1234, client_read_1 = the read-only 0x00AA).
"""
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
tracker = SensorTracker(
["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"]
)
futures = tracker.expect_all(
{
"srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001
"client_read_0": 4660, # client read reg 0x0001 back as the just-written 0x1234
"client_read_1": 170, # client read reg 0x0002 (0x00AA) in the same request
}
)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
await tracker.setup_and_start_scenario(client)
await tracker.await_all(futures)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)