wordclock_esp8266
Software for a Wordclock based on ESP8266 and Neopixel LEDs with PONG, TETRIS, SNAKE game modes
ESPHome Modbus TCP↔RTU Bridge – Transparent bridge for ESP8266/ESP32 that lets multiple Modbus TCP clients communicate…
git clone https://github.com/rosenrot00/esphome_modbus_bridge.gitrosenrot00/esphome_modbus_bridgeThis 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.07.1 | Fixed request queue handling and improved untrusted-client, TCP-send, and RTU-response handling |
| 2026.06.1 | Drop RTU responses that have valid CRC but do not match the active request |
| 2026.04.3 | Added CRC-validated RTU echo/noise stripping for known response types |
| 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.
DE/RE pins or one shared GPIO0x01-0x04Runtime 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.
Since version 2026.06.1, RTU responses are checked against the active request after CRC validation. The bridge verifies matching Unit ID, matching Function Code (or Modbus exception Function Code), and for known response types also the exact expected response length. Standard write responses (0x05, 0x06, 0x0F, 0x10) must also echo the requested address and value or quantity. Read responses do not contain the requested start address, so an old read response with the same Unit ID, Function Code, and byte count cannot be distinguished from the current response. Dropped frames are counted as RTU Mismatch Drops.
When read or write protection is active, untrusted clients are limited to two pending requests, UID 0 broadcasts are dropped, and queued requests from trusted clients are handled before queued untrusted requests. The currently active RTU request is never interrupted.
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_bus
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 (1-8)
# 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 # allows untrusted clients only FC 0x01-0x04 when enabled
# 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; DNS results are cached for 60s
# 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: "RTU Mismatch Drops"
accuracy_decimals: 0
update_interval: 10s
lambda: |-
return (int) id(mb_bridge).get_drops_rtu_mismatch();
- 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 Queue Drops"
accuracy_decimals: 0
update_interval: 10s
lambda: |-
return (int) id(mb_bridge).get_drop_untrusted_queue();
- 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:
unit id + PDU)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:
--host, optional --tcp-port)--serial-port, optional serial settings)Use exactly one mode per call:
--host--serial-portIt 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)
python modbus_rw.py --host 192.168.0.10 --register 0x0010 --count 2 --read
python modbus_rw.py --host 192.168.0.10 --register 0x0010 --count 2 --read_input
python modbus_rw.py --host 192.168.0.10 --register 0x0010 --value 0x1234
python modbus_rw.py --serial-port /dev/ttyUSB0 --baudrate 19200 --parity E --register 0x0010 --count 2 --read
pip install "pymodbus<3.10"more like this
Software for a Wordclock based on ESP8266 and Neopixel LEDs with PONG, TETRIS, SNAKE game modes
A small display for ESPHome and Home Assistant to retrieve the current house consumption and energy price via a sensor…
search projects, people, and tags