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 |
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/REpins 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.
- nilan-cts600-homeassistant thanks to @RichardIstSauer
- ha-solarman thanks to @davidrapan
- Marstek Venus Battery thanks to @ebbenberg
- homeassistant-solax-modbus
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:
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_busesphome:
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();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 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).
[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)
- 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
- 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"