mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 11:26:39 +00:00
[modbus_client] Add read/write multiple registers (FC 0x17) (#18215)
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user