Skip to content

rosenrot00/esphome_modbus_bridge

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

260 Commits
 
 
 
 
 
 
 
 

Repository files navigation

ESPHome (ESP8266/ESP32) Modbus TCP to RTU Bridge

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).

Version Changes
2026.04.2 Added RTU response CRC validation before forwarding TCP responses
2026.04.1 Added optional protection for untrusted clients using trusted_networks and trusted_hosts
2026.03.3 Increased the default rtu_response_timeout from 100 ms to 1000 ms
2026.03.2 If de_pin and re_pin use the same GPIO, the component now enables shared pin use automatically
2026.03.1 Removed deprecated uart_wake_loop_on_rx; ESPHome now enables UART wake-on-RX automatically on ESP32
2026.02.2 Removed duplicate YAML event on_command_sent (use on_rtu_send), validated rtu_response_timeout with min 10 ms, and aligned README defaults/comments
2026.02.1 UART polling lifecycle fixed, RTU timeout is now direct, TCP parsing optimized, and LEN drops split into TCP/RTU counters
2026.01.2 Added separate RS-485 de_pin and re_pin; removed flow_control_pin
2026.01.1 TCP client drops, RTU timeouts, and others are now available to use as HA sensors
2025.12.3 Added uart_wake_loop_on_rx to enable ESPHome’s low-latency UART flag
2025.12.2 Optimizations to recover after IP loss and tighten RTU frame detection
2025.12.1 For more compatibility a crc_bytes_swapped option was added
2025.11.1 enabled was added to allow changing the bridges state during runtime
2025.10.3 Added ESPHome automations for tcp and rtu activities
2025.10.2 Introduced T1.5 waiting time for better modbus rtu frame detection on lower bauds
2025.10.1 Implemented support for multiple bridges to be used with multiple UART interfaces
2025.09.1 Added configurable RS-485 de_pin / re_pin support (separate or shared GPIO)
2025.08.2 Improved RTU response handling (silence-based end detection)
2025.08.1 Added support for multiple concurrent TCP clients with preemption logic
2025.07.1 Initial 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
  • Validates RTU response CRC before forwarding responses to TCP clients
  • Optional write protection for clients outside trusted networks or trusted DNS hosts
  • Optional read protection for clients outside trusted networks or trusted DNS hosts
  • Optional rejection of untrusted TCP clients before Modbus traffic starts
  • 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

Runtime counters and the related example sensors are aggregated across all configured modbus_bridge instances on the same ESP node. They are intended as node-wide diagnostics, not per-bridge counters.

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 use the same GPIO if your RS485 transceiver or module supports a shared direction-control signal.

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: 1000    # 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 use the same GPIO if your RS485 transceiver or module supports a shared direction-control signal)
  # crc_bytes_swapped: false     # allows to swap CRC byte order LO/HI -> HI/LO
  # enabled: true                # allows to enable or disable during runtime

  # Without trusted_networks or trusted_hosts, the options below keep their state
  # but do not block reads, writes, or client connections.

  # reject_untrusted_clients: false            # only effective together with trusted_networks or trusted_hosts
  # protect_reads_for_untrusted_clients: false   # only effective together with trusted_networks or trusted_hosts
  # protect_writes_for_untrusted_clients: false  # only effective together with trusted_networks or trusted_hosts
  # reject_untrusted_clients_switch:
  #   name: "Reject Untrusted Clients"
  # protect_untrusted_reads_switch:
  #   name: "Protect Untrusted Reads"
  # protect_untrusted_writes_switch:
  #   name: "Protect Untrusted Writes"
  # trusted_networks:
  #   - 192.168.1.0/24          # local LAN stays trusted
  #   - 10.0.0.5/32              # single trusted client
  # trusted_hosts:
  #   - otherhouse.example.org   # optional: trusted remote DynDNS/static host; resolved on new connections only

  # 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:
  # Runtime counters below are node-wide totals across all modbus_bridge instances.
  - 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 CRC Drops"
    accuracy_decimals: 0
    update_interval: 10s
    lambda: |-
      return (int) id(mb_bridge).get_drops_rtu_crc();

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

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

  - platform: template
    name: "TCP Untrusted Rejects"
    accuracy_decimals: 0
    update_interval: 10s
    lambda: |-
      return (int) id(mb_bridge).get_reject_untrusted_clients();

  - 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"

About

ESPHome Modbus TCP↔RTU Bridge – Transparent bridge for ESP8266/ESP32 that lets multiple Modbus TCP clients communicate with RTU slaves over UART/RS485.

Topics

Resources

License

Stars

Watchers

Forks

Contributors