diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 136fc73db6..c9ba2e837e 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -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(); diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 86337442c6..da0db13a07 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -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}; diff --git a/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml b/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml new file mode 100644 index 0000000000..a5ce02b342 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml @@ -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 diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 2c437341c6..385707d849 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -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)