[modbus] Only apply turnaround delay after broadcasts (#17209)

This commit is contained in:
Jonathan Swoboda
2026-06-25 11:38:34 -04:00
committed by GitHub
parent 18c7f60410
commit 1d5490fd91
4 changed files with 93 additions and 5 deletions
+11 -5
View File
@@ -92,10 +92,14 @@ int32_t Modbus::tx_delay_remaining() {
int32_t ModbusClientHub::tx_delay_remaining() {
const uint32_t now = millis();
return std::max({(int32_t) 0,
(int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ -
(now - this->last_send_)),
(int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))});
// Turnaround delay only applies after a broadcast: no response is expected, so we must give listening devices
// quiet time to process it before the next request. For normal unicast request/response the received reply already
// provides the inter-frame timing, so adding turnaround there just throttles throughput.
const uint16_t turnaround = this->last_send_was_broadcast_ ? this->turnaround_delay_ms_ : 0;
return std::max(
{(int32_t) 0,
(int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + turnaround - (now - this->last_send_)),
(int32_t) (this->frame_delay_ms_ + turnaround - (now - this->last_modbus_byte_))});
}
bool Modbus::tx_blocked() {
@@ -396,6 +400,7 @@ bool Modbus::send_frame_(const ModbusFrame &frame) {
format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_,
now - this->last_modbus_byte_);
this->last_send_ = now;
this->last_send_was_broadcast_ = frame.size > 0 && frame.data[0] == 0;
return true;
}
@@ -411,7 +416,8 @@ void ModbusClientHub::send_next_frame_() {
ModbusDeviceCommand &command = this->tx_buffer_.front();
if (this->send_frame_(command.frame)) {
this->waiting_for_response_ = std::move(command);
if (!this->last_send_was_broadcast_)
this->waiting_for_response_ = std::move(command);
} else {
if (command.device)
command.device->on_modbus_not_sent();
+1
View File
@@ -63,6 +63,7 @@ class Modbus : public uart::UARTDevice, public Component {
uint32_t last_receive_check_{0};
uint32_t last_send_{0};
uint32_t last_send_tx_offset_{0};
bool last_send_was_broadcast_{false};
uint16_t frame_delay_ms_{5};
uint16_t long_rx_buffer_delay_ms_{0};
@@ -0,0 +1,56 @@
esphome:
name: uart-mock-modbus-bcast
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
# No on_tx injection: a broadcast (address 0) gets no reply on a real bus.
uart_mock:
- id: virtual_uart
baud_rate: 9600
auto_start: true
debug:
modbus:
- uart_id: virtual_uart
id: virtual_modbus
role: client
send_wait_time: 200ms
turnaround_time: 10ms
modbus_controller:
- address: 0
modbus_id: virtual_modbus
update_interval: 60s
id: modbus_controller_bcast
number:
- platform: modbus_controller
modbus_controller_id: modbus_controller_bcast
id: bcast_write
name: "bcast_write"
address: 0x01
register_type: holding
value_type: U_WORD
min_value: 0
max_value: 65535
interval:
- interval: 400ms
then:
- number.set:
id: bcast_write
value: 42
@@ -330,3 +330,28 @@ async def test_uart_mock_modbus_server_controller_multiple(
await tracker.setup_and_start_scenario(client)
await tracker.await_all(futures)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio
async def test_uart_mock_modbus_broadcast(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that broadcast writes (address 0) don't wait for a response.
A controller at address 0 sends broadcast writes that get no reply. The
client must not arm the response timeout for them: otherwise every write
blocks for send_wait_time and logs a spurious "no response from 0" warning.
"""
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected(),
):
# Several broadcast writes fire on the 400ms interval; send_wait_time is
# 200ms, so the old behaviour would have warned on each one by now.
await asyncio.sleep(3.0)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)