An async Python proxy that accepts Modbus TCP, Modbus RTU-over-TCP, and Modbus RTU clients on arbitrary upstream listeners, then forwards supported requests to one downstream Modbus RTU bus through one serialized worker. The current implementation is read-only and supports Modbus function codes 0x03 (Read Holding Registers) and 0x04 (Read Input Registers).
uv syncThis project depends on pymodbus[serial], so serial support is included.
version: 1
logging:
level: INFO
frame_checks: false
queue:
size: 100
timing:
response_delay: null
downstream_options:
connect_retries: 3
remote_device_id: null
downstream:
port: /dev/ttyUSB0
baudrate: 9600
bytesize: 8
parity: N
stopbits: 1
timeout: 1.0
listeners:
- name: evcc
type: tcp
host: 0.0.0.0
port: 5020
protocol: tcp
- name: victron-rtu-over-tcp
type: tcp
host: 0.0.0.0
port: 5021
protocol: rtu-over-tcp
- name: cerbo-gx
type: serial
port: /dev/ttyUSB1
baudrate: 9600
bytesize: 8
parity: N
stopbits: 1
timeout: 1.0uv run modbus-multiplexer --config config.yamlExample with a fixed RTU unit id:
downstream_options:
remote_device_id: 1Example for clients that send Modbus RTU frames over a TCP socket:
listeners:
- name: rtu-over-tcp
type: tcp
host: 0.0.0.0
port: 502
protocol: rtu-over-tcpIf downstream_options.remote_device_id is not provided, the proxy forwards the incoming client unit id to the RTU side.
Use the configured upstream listeners:
- TCP host and port: configured per TCP listener
- TCP protocol:
tcporrtu-over-tcp - serial RTU port and serial settings: configured per serial listener
- function codes:
0x03and0x04
uv run ruff check --fix
uv run ruff format
uv run ty check
uv run pytestThe CLI entrypoint is implemented in src/modbus_multiplexer/__main__.py. The only runtime option is --config.
Configuration keys:
version: config format version, currently1logging.level: Python logging level, defaultINFOlogging.frame_checks: include PyModbus RTU frame-check debug logs, defaultfalsequeue.size: maximum pending RTU requests, default100timing.response_delay: optional delay in seconds before replying upstream after a downstream read, defaultnulldownstream: required downstream serial RTU bus settingsdownstream.port: serial device path, requireddownstream.baudrate: serial baud rate, default9600downstream.bytesize: serial byte size, default8downstream.parity: serial parity,N,E, orO, defaultNdownstream.stopbits: serial stop bits, default1downstream.timeout: RTU request timeout in seconds, default1.0downstream_options.remote_device_id: optional fixed RTU unit iddownstream_options.connect_retries: PyModbus serial reconnect retries, default3listeners: non-empty list of upstream listenerslisteners[].name: unique listener name, requiredlisteners[].type:tcporserial, required- TCP listener keys:
host,port,protocol - serial listener keys:
port,baudrate,bytesize,parity,stopbits,timeout
Current limitations:
- only read functions
0x03and0x04are forwarded - write requests are rejected
- no end-to-end simulator or hardware integration test is included yet
The main design goal is protecting the downstream RTU bus from concurrent access. Multiple upstream clients may talk to the proxy at once over any configured TCP or serial listener, but all downstream RTU requests are serialized through a bounded queue and one async worker.
Key modules:
src/modbus_multiplexer/proxy.py: queueing, dispatch, exception mapping, datastore-facing proxy contextsrc/modbus_multiplexer/pymodbus_runtime.py: PyModbus TCP/serial server and RTU client wiringsrc/modbus_multiplexer/config.py: YAML configuration loading, validation, and runtime configuration dataclassestests/test_proxy.py: unit tests for serialization and failure behavior
Request flow:
- a Modbus TCP, RTU-over-TCP, or serial RTU client sends a read request to one configured upstream listener
- the corresponding PyModbus listener decodes the request and passes datastore access into the proxy context
- the proxy context enqueues the request for the RTU worker
- the worker performs exactly one RTU operation at a time
- registers or Modbus exception codes are returned to the upstream client
Error handling behavior:
- full queue:
DEVICE_BUSY - timeout:
GATEWAY_NO_RESPONSE - RTU connection failure:
GATEWAY_PATH_UNAVIABLE - unsupported function:
ILLEGAL_FUNCTION - unexpected internal error:
DEVICE_FAILURE
The implementation guidance for future changes is documented in AGENT.md.
All code in this project was generated with AI assistance and then reviewed by a human maintainer.