ESPHome (ESP8266/ESP32) Modbus TCP to RTU Bridge

February 8, 2026 · View on GitHub

This ESPHome component provides a transparent Modbus TCP-to-RTU bridge for ESP8266 and ESP32. It acts as a Modbus RTU master over UART and allows multiple Modbus TCP clients to communicate with Modbus RTU slaves via RS485 (or other UART-compatible interfaces).

VersionChanges
2026.02.2Removed duplicate YAML event on_command_sent (use on_rtu_send), validated rtu_response_timeout with min 10 ms
2026.02.1UART polling lifecycle fixed, RTU timeout is now direct (default 100 ms), TCP parsing optimized, and LEN drops split into TCP/RTU counters
2026.01.2Added separate RS-485 de_pin and re_pin; removed flow_control_pin
2026.01.1TCP client drops, RTU timeouts, and others are now available to use as HA sensors
2025.12.3Added uart_wake_loop_on_rx to enable ESPHome’s low-latency UART flag
2025.12.2Optimizations to recover after IP loss and tighten RTU frame detection
2025.12.1For more compatibility a crc_bytes_swapped option was added
2025.11.1enabled was added to allow changing the bridges state during runtime
2025.10.3Added ESPHome automations for tcp and rtu activities
2025.10.2Introduced T1.5 waiting time for better modbus rtu frame detection on lower bauds
2025.10.1Implemented support for multiple bridges to be used with multiple UART interfaces
2025.09.1Added configurable RS-485 de_pin / re_pin support (separate or shared GPIO)
2025.08.2Improved RTU response handling (silence-based end detection)
2025.08.1Added support for multiple concurrent TCP clients with preemption logic
2025.07.1Initial public README and Python modbus_rw.py tool

Features

The bridge listens on a configurable TCP port (default: 502) and expects standard Modbus TCP frames from clients. Each request is translated into a Modbus RTU frame, transmitted over UART, and the response is converted back into Modbus TCP and returned to the client.

  • Connect Modbus TCP software to Modbus RTU devices over one ESP
  • Works on both ESP32 and ESP8266
  • Supports multiple TCP clients at the same time
  • Configurable port, timeouts, and client limits
  • Supports RS-485 transceivers with separate DE/RE pins or one shared GPIO
  • Can run multiple bridges in one node (for multiple UART buses)
  • Auto-recovers after network/IP loss
  • Integrates easily with Home Assistant and ESPHome automations

Proven Compatibility

Hardware Setup

The following diagram shows how an ESP32 is connected to an RS485 transceiver (e.g., MAX3485, SP3485, SN65HVD…) before the RS485 differential lines are attached to a Modbus bus.

   +---------------------------+        +----------------------------------+
   | ESP32 / ESP8266           |        | RS485 Transceiver                |
   | (UART side)               |        | (e.g. MAX3485 / SP3485 / SN65HVD)|
   +---------------------------+        +----------------------------------+
   | TX (UART TX)  -----------+------->| DI   (Driver Input)               |
   | RX (UART RX)  <----------+--------| RO   (Receiver Output)            |
   | DE (optional) -----------+------->| DE   (Driver Enable)              |
   | RE (optional) -----------+------->| /RE  (Receiver Enable, low=ON)    |
   | GND ---------------------+--------| GND                               |
   +---------------------------+        +----------------------------------+
                                                     |              |
                                                     |              |
                                                     v              v
                                                 A ---------------- B
                                              RS485 differential pair

DE and RE may be wired to the same GPIO if your transceiver ties DE and /RE together.

ESPHome Configuration Examples

Minimal YAML
esphome:
  name: modbus-bridge
  friendly_name: Modbus TCP-to-RTU bridge

esp32:
  board: esp32dev
  framework:
    type: esp-idf

logger:
api:
ota:
  platform: esphome

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

external_components:
  - source:
      type: git
      url: https://github.com/rosenrot00/esphome_modbus_bridge
    components: [modbus_bridge]

uart:
  id: uart_bus
  tx_pin: GPIO17
  rx_pin: GPIO16
  baud_rate: 9600

modbus_bridge:
  id: mb_bridge
  uart_id: uart_bus
Full YAML (all options, automations, sensors)
esphome:
  name: modbus-bridge
  friendly_name: Modbus TCP-to-RTU bridge

  # Run on boot: publish whether the bridge is enabled
  on_boot:
    priority: 600
    then:
      - lambda: |-
          id(mb_bridge_enabled).publish_state(id(mb_bridge).is_enabled());

esp32:
  board: esp32dev
  framework:
    type: esp-idf                    # ESP-IDF recommended
    #type: arduino                   # Arduino also works

# Enable logging over UART
logger:

# Enable Home Assistant API
api:

# Enable OTA updates
ota:
  platform: esphome
  password: !secret ota_password      # https://esphome.io/guides/security_best_practices/#using-secretsyaml

wifi:
  ssid: !secret wifi_ssid             # https://esphome.io/guides/security_best_practices/#using-secretsyaml
  password: !secret wifi_password     # https://esphome.io/guides/security_best_practices/#using-secretsyaml
  # min_auth_mode: WPA3               # Optional: Default is WPA2 on ESP32
  # domain: .lan                      # Optional: Default is local 

  # Fallback hotspot if WiFi fails
  ap:
    ssid: "Modbus TCP-to-RTU bridge Hotspot"
    password: !secret ap_password     # https://esphome.io/guides/security_best_practices/#using-secretsyaml

captive_portal:

external_components:
  - source:
      type: git
      url: https://github.com/rosenrot00/esphome_modbus_bridge
    components: [modbus_bridge]

# UART hardware configuration: Modbus RTU (RS-485)
uart:
  id: uart_bus
  tx_pin: GPIO17
  rx_pin: GPIO16
  baud_rate: 9600
  # stop_bits: 1                 # Optional: Default is 1
  # parity: NONE                 # Optional: Default is NONE
  rx_buffer_size: 512            # default is 256; increase for long RTU responses

# Modbus bridge configuration: TCP server <-> UART RTU translator
modbus_bridge:
  id: mb_bridge
  uart_id: uart_bus
  tcp_port: 502                  # TCP port to listen on
  # rtu_response_timeout: 100     # ms, internally clamped to >=10 ms
  # tcp_client_timeout: 60000    # ms of inactivity before client is disconnected
  # tcp_allowed_clients: 2       # number of simultaneous TCP clients (min 1)
  # tcp_poll_interval: 50        # ms between TCP polls
  # de_pin: GPIO18               # Optional: RS-485 Driver Enable (DE)
  # re_pin: GPIO19               # Optional: RS-485 Receiver Enable (/RE) - de_pin and re_pin can be the same GPIO
  # (DE and /RE may be the same GPIO if the transceiver ties them together)
  # crc_bytes_swapped: false     # allows to swap CRC byte order LO/HI -> HI/LO
  # enabled: true                # allows to enable or disable during runtime
  # uart_wake_loop_on_rx: false  # default false; set true to enable ESPHome's UART low-latency RX wake

  # Event: triggered whenever number of TCP clients changes
  on_tcp_clients_changed:
    then:
      - lambda: |-
          id(tcp_clients) = count;
      - logger.log:
          format: "TCP clients connected: %d"
          args: ['count']
      - sensor.template.publish:
            id: mb_tcp_clients
            state: !lambda |-
              return (int) count;

  # Other available events (use similarly):
  # on_rtu_send:       # (function_code, address) – triggered for every RTU command sent
  # on_rtu_receive:    # (function_code, address) – triggered for every valid RTU response
  # on_rtu_timeout:    # (function_code, address) – triggered for RTU timeouts
  # on_tcp_started:    # () – triggered when TCP server successfully starts
  # on_tcp_stopped:    # () – triggered when TCP server stops or IP is lost

# Output pin for status LED
output:
  - platform: gpio
    id: output_led_status
    pin: GPIO2

# Binary LED light entity
light:
  - platform: binary
    id: led_status
    name: "Status LED"
    output: output_led_status

# Global variable to store connected TCP client count
globals:
  - id: tcp_clients
    type: int
    restore_value: no
    initial_value: '0'

# Every 3 seconds, blink the LED N times (N = connected TCP clients)
interval:
  - interval: 3s
    then:
      - if:
          condition:
            lambda: 'return id(tcp_clients) > 0;'
          then:
            - repeat:
                count: !lambda 'return id(tcp_clients);'
                then:
                  - light.turn_on: led_status
                  - delay: 100ms
                  - light.turn_off: led_status
                  - delay: 100ms  # short pause between blinks

switch:
  # Switch: enable/disable verbose Modbus debugging
  - platform: template
    name: "Modbus Bridge Debug"
    id: modbus_debug_switch
    restore_mode: RESTORE_DEFAULT_OFF
    turn_on_action:
      - lambda: |-
          id(mb_bridge).set_debug(true);
          id(modbus_debug_switch).publish_state(true);
    turn_off_action:
      - lambda: |-
          id(mb_bridge).set_debug(false);
          id(modbus_debug_switch).publish_state(false);

  # Switch: enable/disable the Modbus bridge itself
  - platform: template
    id: mb_bridge_enabled
    name: "Modbus Bridge Enabled"
    restore_mode: "ALWAYS_ON"
    optimistic: true
    turn_on_action:
      - lambda: |-
          id(mb_bridge).set_enabled(true);
    turn_off_action:
      - lambda: |-
          id(mb_bridge).set_enabled(false);

sensor:
  - platform: template
    name: "TCP Clients"
    id: mb_tcp_clients
    accuracy_decimals: 0
    update_interval: never

  #- platform: template
  #  name: "MB Frames In"
  #  accuracy_decimals: 0
  #  update_interval: 10s
  #  lambda: |-
  #    return (float) id(mb_bridge).get_frames_in();

  #- platform: template
  #  name: "MB Frames Out"
  #  accuracy_decimals: 0
  #  update_interval: 10s
  #  lambda: |-
  #    return (float) id(mb_bridge).get_frames_out();

  - platform: template
    name: "TCP Drops PID"
    accuracy_decimals: 0
    update_interval: 10s
    lambda: |-
      return (int) id(mb_bridge).get_drops_pid();

  - platform: template
    name: "TCP Drops LEN"
    accuracy_decimals: 0
    update_interval: 10s
    lambda: |-
      return (int) id(mb_bridge).get_drops_tcp_len();

  - platform: template
    name: "RTU Incomplete Drops"
    accuracy_decimals: 0
    update_interval: 10s
    lambda: |-
      return (int) id(mb_bridge).get_drops_rtu_incomplete();

  - platform: template
    name: "RTU Timeouts"
    accuracy_decimals: 0
    update_interval: 10s
    lambda: |-
      return (int) id(mb_bridge).get_timeouts();

  - platform: template
    name: "TCP Clients Total"
    accuracy_decimals: 0
    update_interval: 10s
    lambda: |-
      return (int) id(mb_bridge).get_clients_connected_total();

  - platform: template
    name: "TCP No Slot Events"
    accuracy_decimals: 0
    update_interval: 10s
    lambda: |-
      return (int) id(mb_bridge).get_noslot_events();

  - platform: template
    name: "TCP Preempt Events"
    accuracy_decimals: 0
    update_interval: 10s
    lambda: |-
      return (int) id(mb_bridge).get_preempt_events();

Modbus TCP Request Format

Each Modbus TCP request must follow this format:

  • Transaction ID: 2 bytes (arbitrary, echoed back)
  • Protocol ID: 2 bytes (must be 0)
  • Length: 2 bytes (number of following bytes, typically unit id + PDU)
  • Unit ID: 1 byte (RTU slave address)
  • PDU: n bytes (Function code and data)

Example (read holding registers, unit ID 1, starting at 0x0000, count 1):

00 01   - Transaction ID
00 00   - Protocol ID
00 06   - Length
01      - Unit ID (RTU address)
03      - Function code (Read Holding Registers)
00 00   - Start address high/low
00 01   - Register count high/low

The response will match the Modbus TCP format and contain the same transaction ID.

modbus_rw.py – Modbus TCP/RTU Register Read/Write Tool

modbus_rw.py is a simple command-line utility for reading and writing Modbus registers using pymodbus. It supports:

  • Modbus TCP mode (--host, optional --tcp-port)
  • Modbus RTU serial mode (--serial-port, optional serial settings)

Use exactly one mode per call:

  • TCP: set --host
  • RTU: set --serial-port

It supports reading Holding Registers (Function Code 0x03), Input Registers (0x04), and writing a single Holding Register (0x06).

Arguments

[Mode selection]
--host           Modbus TCP server IP (TCP mode)
--tcp-port       Modbus TCP port (default: 502)
--serial-port    Serial port (RTU mode), e.g. /dev/ttyUSB0 or COM3

[RTU serial options]
--baudrate       RTU baudrate (default: 9600)
--parity         RTU parity: N/E/O (default: N)
--stopbits       RTU stop bits: 1/2 (default: 1)
--bytesize       RTU byte size: 7/8 (default: 8)

[Common options]
--timeout        Request timeout in seconds (default: 1.0)
--unit           Modbus unit/slave ID (default: 1)
--register       Register address (decimal or hex, required)
--count          Number of registers to read (default: 1)
--value          Value to write (decimal or hex)
--read           Read Holding Registers (FC 0x03)
--read_input     Read Input Registers (FC 0x04)

Examples

  • Read Holding Registers (FC 0x03)
python modbus_rw.py --host 192.168.0.10 --register 0x0010 --count 2 --read
  • Read Input Registers (FC 0x04)
python modbus_rw.py --host 192.168.0.10 --register 0x0010 --count 2 --read_input
  • Write a Single Holding Register (FC 0x06)
python modbus_rw.py --host 192.168.0.10 --register 0x0010 --value 0x1234
  • Read Holding Registers with USB/serial adapter (FC 0x03)
python modbus_rw.py --serial-port /dev/ttyUSB0 --baudrate 19200 --parity E --register 0x0010 --count 2 --read

Requirements

  • Python 3.x
  • pymodbus ≤3.9.x library (let me know if you need it compatible with >3.10)
  • Install via: pip install "pymodbus<3.10"