[modbus] Drop a deferred server reply when another frame arrives first (#19534)

This commit is contained in:
Bonne Eggleston
2026-09-23 17:33:00 +01:00
committed by GitHub
parent cf3552b46e
commit d78d640abd
3 changed files with 136 additions and 1 deletions
+7
View File
@@ -203,6 +203,12 @@ void ModbusClientHub::parse_modbus_frames() {
void ModbusServerHub::parse_modbus_frames() {
while (!this->rx_buffer_.empty()) {
if (this->deferred_payload_len_ != 0) {
// Another frame arrived before the deferred reply went out, so the client has moved on.
this->cancel_timeout("deferred_send");
ESP_LOGD(TAG, "Dropped deferred reply to %" PRIu8 ": a new frame arrived first", this->deferred_payload_[0]);
this->deferred_payload_len_ = 0;
}
size_t size = this->rx_buffer_.size();
ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size);
bool retry_as_client = false;
@@ -1213,6 +1219,7 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) {
this->set_timeout("deferred_send", (this->tx_delay_remaining() + US_PER_MS - 1) / US_PER_MS, [this]() {
ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1,
this->deferred_payload_len_ - 1);
this->deferred_payload_len_ = 0;
if (!this->send_frame_(frame)) {
ESP_LOGE(TAG, "Deferred server reply dropped: transmission still blocked");
}
@@ -26,6 +26,20 @@ uart_mock:
rx_timeout: 2
auto_start: false
debug:
# Each burst-case reply from device 1 (single-register FC 0x03, told apart by its register
# value) fires its own sensor when it reaches the wire. One sensor per reply, because the API
# merges updates to the same entity that land within its batching window.
on_tx:
- then:
- lambda: |-
if (data.size() != 7 || data[0] != 0x01 || data[1] != 0x03 || data[3] != 0x00)
return;
switch (data[4]) {
case 0xA1: id(burst_tx_a).publish_state(1); break;
case 0xB2: id(burst_tx_b).publish_state(1); break;
case 0xC3: id(burst_tx_before_peer).publish_state(1); break;
case 0xD4: id(burst_tx_probe).publish_state(1); break;
}
injections:
- delay: 100ms
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
@@ -52,6 +66,21 @@ uart_mock:
- delay: 100ms
inject_rx:
[0x01, 0x17, 0x00, 0x06, 0x00, 0x01, 0x00, 0x06, 0x00, 0x01, 0x02, 0x56, 0x78, 0x8B, 0x55]
# Two reads of device 1 (regs 0x0B then 0x0C) in one injection so both land in the rx buffer
# together. The reply to 0x0B is deferred because 0x0C is still queued behind it, and must be
# dropped once 0x0C is parsed: only the reply to 0x0C may reach the wire (burst_read_a/b).
- delay: 100ms
inject_rx: [0x01, 0x03, 0x00, 0x0B, 0x00, 0x01, 0xF5, 0xC8,
0x01, 0x03, 0x00, 0x0C, 0x00, 0x01, 0x44, 0x09]
# Read of device 1 (reg 0x0D) followed in the same injection by a read of device 2. The client
# has moved on to another device, so the deferred reply to 0x0D must never be sent
# (burst_read_before_peer).
- delay: 100ms
inject_rx: [0x01, 0x03, 0x00, 0x0D, 0x00, 0x01, 0x15, 0xC9,
0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8]
# Plain read of device 1 (reg 0x0E) whose reply on the wire marks the burst cases as settled.
- delay: 100ms
inject_rx: [0x01, 0x03, 0x00, 0x0E, 0x00, 0x01, 0xE5, 0xC9]
globals:
- id: stored_1
@@ -110,6 +139,24 @@ modbus_server:
read_lambda: |-
id(read_after_peer_timeout).publish_state(1);
return 1;
- address: 0x0B
value_type: U_WORD
read_lambda: |-
id(burst_read_a).publish_state(1);
return 0xA1;
- address: 0x0C
value_type: U_WORD
read_lambda: |-
id(burst_read_b).publish_state(1);
return 0xB2;
- address: 0x0D
value_type: U_WORD
read_lambda: |-
id(burst_read_before_peer).publish_state(1);
return 0xC3;
- address: 0x0E
value_type: U_WORD
read_lambda: return 0xD4;
sensor:
- platform: template
@@ -136,6 +183,27 @@ sensor:
- platform: template
name: "rw_read_3"
id: rw_read_3
- platform: template
name: "burst_read_a"
id: burst_read_a
- platform: template
name: "burst_read_b"
id: burst_read_b
- platform: template
name: "burst_read_before_peer"
id: burst_read_before_peer
- platform: template
name: "burst_tx_a"
id: burst_tx_a
- platform: template
name: "burst_tx_b"
id: burst_tx_b
- platform: template
name: "burst_tx_before_peer"
id: burst_tx_before_peer
- platform: template
name: "burst_tx_probe"
id: burst_tx_probe
button:
- platform: template
+61 -1
View File
@@ -260,11 +260,71 @@ async def test_uart_mock_modbus_server_read_write(
api_client_connected() as client,
):
await tracker.setup_and_start_scenario(client)
# The FC 0x17 injections fire last, behind four earlier 100ms delays
# The FC 0x17 injections fire behind four earlier 100ms delays
await tracker.await_all(futures, timeout=4.0)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
@pytest.mark.asyncio
async def test_uart_mock_modbus_server_burst(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that a server reply deferred behind a queued frame is dropped.
Two requests are injected as one chunk so both sit in the rx buffer at
once. The reply to the first is deferred because the second is still
queued, and must be discarded once the second frame is parsed:
* device 1 reg 0x0B then device 1 reg 0x0C -- only the 0x0C reply is sent;
* device 1 reg 0x0D then a device 2 request -- nothing is sent.
The fixture's on_tx hook fires a sensor per burst reply that reaches the
wire, and a final plain read marks both cases settled once its reply is
seen.
"""
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
tracker = SensorTracker(
[
"burst_read_a",
"burst_read_b",
"burst_read_before_peer",
"burst_tx_a",
"burst_tx_b",
"burst_tx_before_peer",
"burst_tx_probe",
]
)
futures = tracker.expect_all(
{
"burst_read_a": 1,
"burst_read_b": 1,
"burst_read_before_peer": 1,
"burst_tx_b": 1,
"burst_tx_probe": 1,
}
)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
await tracker.setup_and_start_scenario(client)
# Every request is parsed and served by its read_lambda regardless of
# whether its reply reaches the wire.
await tracker.await_all(futures, timeout=4.0)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
assert not tracker.sensor_states["burst_tx_a"], (
"reply to reg 0x0B must be dropped, a later request was queued behind it"
)
assert not tracker.sensor_states["burst_tx_before_peer"], (
"reply to reg 0x0D must be dropped, the client moved on to device 2"
)
@pytest.mark.asyncio
async def test_uart_mock_modbus_server_read_write_invalid(
yaml_config: str,