From d78d640abd1b009398e7604bbf3d33801b4d826f Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Wed, 23 Sep 2026 09:33:00 -0700 Subject: [PATCH] [modbus] Drop a deferred server reply when another frame arrives first (#19534) --- esphome/components/modbus/modbus.cpp | 7 ++ .../uart_mock_modbus_server_injected.yaml | 68 +++++++++++++++++++ tests/integration/test_uart_mock_modbus.py | 62 ++++++++++++++++- 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 037901a873..eae418ba3d 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -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"); } diff --git a/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml b/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml index 2cd1c610f1..8b2113ccd9 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml @@ -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 diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 36aa9a9668..1b877f5948 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -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,