Complete guide: Modbus TCP and RTU on Raspberry PLCs, with Python and Node-RED
PyModbus is the Python library for Modbus TCP and Modbus RTU. On a Raspberry PLC you install it in a Python virtual environment with pip install "pymodbus[serial]", and then the PLC can be a Modbus client (master) that reads meters and drives, or a Modbus server (slave) that a SCADA reads. Use the synchronous client for simple scripts, and the asyncio client when one program must poll several devices or serve data at the same time.
This guide shows the asynchronous side of pymodbus with two tested programs: a Modbus TCP server that publishes the CPU temperature of the PLC, and an asyncio client that polls two PLCs at the same time. Both were run with pymodbus 3.16.1 and Python 3.13, over Modbus TCP and over Modbus RTU on a virtual serial link. For the wiring, the RS-485 port names and the synchronous examples, see the complete Modbus guide for the Raspberry PLC.
What is pymodbus?
pymodbus is an open source Modbus stack written in Python, under the BSD-3-Clause license. It has clients and servers for Modbus TCP, Modbus RTU and ASCII on serial lines, Modbus over TLS and over UDP, each one with a synchronous API and an asyncio API. On a Raspberry PLC it runs on Raspberry Pi OS, like any Python program.
| Role on the Raspberry PLC | pymodbus class | Typical use |
|---|---|---|
| Modbus TCP client (master) | ModbusTcpClient, AsyncModbusTcpClient | Read energy meters, drives and remote I/O on Ethernet |
| Modbus RTU client (master) | ModbusSerialClient, AsyncModbusSerialClient | Poll slaves on the RS-485 line |
| Modbus TCP server (slave) | StartTcpServer, StartAsyncTcpServer | Give PLC values to a SCADA, an HMI or another PLC |
| Modbus RTU server (slave) | StartSerialServer, StartAsyncSerialServer | Answer a master on the RS-485 line |
Install pymodbus on the Raspberry PLC
Raspberry Pi OS based on Debian 12 (Bookworm) and later blocks pip install outside a virtual environment, so create one. pymodbus 3.9 and later need Python 3.10 or later.
python3 -m venv ~/modbus-venv
source ~/modbus-venv/bin/activate
pip install "pymodbus[serial]==3.16.1"
python -c "import pymodbus; print(pymodbus.__version__)"
The [serial] option installs pyserial, which Modbus RTU needs. Fix the version in your project: pymodbus changes its API often, and the next major version (v4) removes the old datastore classes.
Synchronous or asynchronous pymodbus
Both APIs send the same Modbus frames. The difference is how your program waits for the answers.
| Synchronous | Asynchronous (asyncio) | |
|---|---|---|
| How a request waits | The call blocks until the answer or the timeout | The call is awaited, and other tasks run while it waits |
| Several devices | One after the other: a dead device delays all the others by its timeout | One task per device: a dead device delays only its own task |
| Client and server in one program | Needs threads and locks | Tasks in the same event loop |
| Code | Shorter, easier to debug | async and await in every function that talks Modbus |
| Good for | A script that reads one device, tests, cron jobs | Gateways, data loggers, a PLC that is client and server at the same time |
A pymodbus client is not thread safe: do not share one client between threads. With asyncio, one task per device and one client per task avoids the problem.
Modbus TCP server on the Raspberry PLC with live values
This server answers as device 1 on TCP port 5020. It has four separate tables of 10 values: coils, discrete inputs, holding registers and input registers. Before each read of registers, the refresh function writes the CPU temperature of the PLC to holding register 0 and the seconds since start to register 1. In the same way you can publish the inputs of the PLC or values from your own program.
#!/usr/bin/env python3
"""Modbus TCP server (slave) on a Raspberry PLC with pymodbus 3.16.
Holding register 0: CPU temperature in tenths of a degree (452 = 45.2 C)
Holding register 1: seconds since the server started
Coils 0 to 9: free coils that a client can write and read
"""
import asyncio
import time
from pymodbus.server import StartAsyncTcpServer
from pymodbus.simulator import DataType, SimData, SimDevice
PORT = 5020 # 502 needs root, see the note in the text
DEVICE_ID = 1 # unit identifier that this server answers to
TEMP_FILE = "/sys/class/thermal/thermal_zone0/temp"
START = time.monotonic()
def cpu_temp_tenths():
with open(TEMP_FILE) as f:
return int(f.read()) // 100 # millidegrees to tenths of a degree
async def refresh(function_code, start_address, address, count,
current_registers, set_values):
"""Called by pymodbus on every request, before it answers."""
if function_code in (3, 4): # read holding or input registers
current_registers[0] = cpu_temp_tenths()
current_registers[1] = int(time.monotonic() - START) & 0xFFFF
return None # no exception: answer normally
device = SimDevice(
DEVICE_ID,
# Four separate tables: coils, discrete inputs, holding, input registers
simdata=(
[SimData(0, count=10, values=False, datatype=DataType.BITS)],
[SimData(0, count=10, values=False, datatype=DataType.BITS)],
[SimData(0, count=10, values=0, datatype=DataType.REGISTERS)],
[SimData(0, count=10, values=0, datatype=DataType.REGISTERS)],
),
action=refresh,
)
async def main():
# 0.0.0.0 listens on all interfaces: eth0, eth1 and localhost
await StartAsyncTcpServer(device, address=("0.0.0.0", PORT))
if __name__ == "__main__":
asyncio.run(main())
On Linux, only root can open TCP ports under 1024, so a normal user who starts a server on port 502 gets a PermissionError. The test uses 5020. In production, run the service with the rights it needs or redirect port 502 to the port of your server. This server uses SimDevice and SimData, the datastore of pymodbus 3.12 and later. The old ModbusSlaveContext no longer exists.
Asyncio Modbus client that polls two devices at the same time
The client runs one task per device with asyncio.gather. Every 2 seconds each task inverts coil 0 (FC05), reads it back (FC01) and reads the two holding registers (FC03). If a device does not answer, its task waits 5 seconds and connects again, and the other task continues.
#!/usr/bin/env python3
"""Asynchronous Modbus TCP client (master) with pymodbus 3.16.
Polls two devices at the same time. Each device has its own task, so a
slow or missing device does not delay the other one.
"""
import asyncio
from pymodbus.client import AsyncModbusTcpClient
from pymodbus.exceptions import ModbusException
DEVICES = [
# name, IP address, TCP port, unit identifier
("plc-a", "127.0.0.1", 5020, 1),
("plc-b", "127.0.0.1", 5021, 1),
]
POLL_S = 2.0
async def poll(name, host, port, device_id):
client = AsyncModbusTcpClient(host, port=port, timeout=1, retries=1)
coil = False
while True:
if not client.connected and not await client.connect():
print(f"{name}: not reachable, new try in 5 s")
await asyncio.sleep(5)
continue
try:
coil = not coil
await client.write_coil(0, coil, device_id=device_id) # FC05
rc = await client.read_coils(0, count=1, device_id=device_id) # FC01
rr = await client.read_holding_registers(0, count=2, device_id=device_id) # FC03
if rc.isError() or rr.isError():
print(f"{name}: exception response {rc} {rr}")
else:
temp = rr.registers[0] / 10
print(f"{name}: coil 0 = {rc.bits[0]}, CPU {temp:.1f} C, "
f"uptime {rr.registers[1]} s")
except ModbusException as exc:
print(f"{name}: no valid answer ({exc})")
client.close() # reconnect on the next cycle
await asyncio.sleep(POLL_S)
async def main():
await asyncio.gather(*(poll(*d) for d in DEVICES))
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
Output with two servers running, and then with the second server stopped:
plc-a: coil 0 = True, CPU 45.2 C, uptime 1 s
plc-b: coil 0 = True, CPU 45.2 C, uptime 1 s
plc-a: coil 0 = False, CPU 45.2 C, uptime 3 s
plc-b: coil 0 = False, CPU 45.2 C, uptime 3 s
...
plc-b: not reachable, new try in 5 s
plc-a: coil 0 = True, CPU 45.2 C, uptime 9 s
plc-a: coil 0 = False, CPU 45.2 C, uptime 11 s
To test on one PLC, start the server twice, with PORT = 5020 and PORT = 5021, and run the client in a third terminal. On a real network, write the IP address of each PLC or device in DEVICES and use port 502.
Modbus RTU on the RS-485 port with asyncio
For Modbus RTU, change only the client or the server constructor. The RS-485 port is /dev/ttySC0 to /dev/ttySC3, depending on the version of the Raspberry PLC (list them with ls /dev/ttySC*).
import asyncio
from pymodbus.client import AsyncModbusSerialClient
from pymodbus.framer import FramerType
async def main():
client = AsyncModbusSerialClient(
"/dev/ttySC0", # RS-485 port, check the name for your PLC version
framer=FramerType.RTU,
baudrate=9600, bytesize=8, parity="N", stopbits=1,
timeout=1,
)
if not await client.connect():
print("RS-485 port not available")
return
rr = await client.read_holding_registers(0, count=2, device_id=1)
if rr.isError():
print(f"no valid answer: {rr}")
else:
print(f"CPU {rr.registers[0] / 10:.1f} C, uptime {rr.registers[1]} s")
client.close()
if __name__ == "__main__":
asyncio.run(main())
On the server side, StartAsyncSerialServer(device, port="/dev/ttySC0", framer=FramerType.RTU, baudrate=9600) replaces StartAsyncTcpServer. An RS-485 line is half duplex and has one master: asyncio does not make the line faster. Poll the slaves of one line one after the other in a single task, and use parallel tasks for different lines or for Modbus TCP devices.
Old pymodbus examples fail with pymodbus 3.16
The first version of this post used pymodbus 3.5. With pymodbus 3.16.1 that code stops at the first line that changed:
| Old code | Error with 3.16.1 | Code now |
|---|---|---|
from pymodbus.transaction import ModbusSocketFramer | ImportError | from pymodbus.framer import FramerType, then framer=FramerType.SOCKET or FramerType.RTU |
ModbusSlaveContext(di=..., co=..., hr=..., ir=...) | ImportError | SimDevice(1, simdata=(...)) with SimData |
client.write_coil(0, a, 0x01) | TypeError: takes 3 positional arguments | client.write_coil(0, a, device_id=1) |
client.read_coils(0, 1, slave=1) | TypeError: unexpected keyword argument 'slave' | client.read_coils(0, count=1, device_id=1) |
The complete list of changes by version is in the Modbus guide for the Raspberry PLC and in the pymodbus documentation.
See Modbus RTU and TCP in a real network. The Modbus network simulator shows a PLC that polls four RTU slaves and serves the values over Modbus TCP, with real frames, timeouts and word order errors. In the onboard data gateway case study, a Raspberry PLC reads the propulsion data of a ship over Modbus and sends it to shore over MQTT.
FAQ about pymodbus on the Raspberry PLC
Which pymodbus version should I use?
The examples were tested with pymodbus 3.16.1, the latest version in October 2026. Fix the version with pip install "pymodbus[serial]==3.16.1", so that an update does not break your program.
Why does pip install pymodbus fail on the Raspberry PLC?
Raspberry Pi OS based on Bookworm or later shows "externally-managed-environment" when you use pip outside a virtual environment. Create one with python3 -m venv and install pymodbus there.
Can the Raspberry PLC be Modbus client and server at the same time?
Yes. With asyncio, run the server and the client tasks in the same event loop, or run two programs. On a serial port, only one program can use the port.
Why does a pymodbus server on port 502 fail?
Ports under 1024 need root rights on Linux. Use a port such as 5020 for tests, and give the service the rights it needs or redirect port 502 in production.
Is async faster on an RS-485 line?
No. One RS-485 line carries one request at a time. Async helps when one program talks to several lines or several Modbus TCP devices.
More about Modbus on the Raspberry PLC
- Modbus TCP and RTU guide for Raspberry Pi PLCs: Python and Node-RED
- Setting up Modbus on a Raspberry Pi PLC with CODESYS
- Introduction to Modbus and Raspberry PLC, part 1
- Modbus RTU frame, function codes and CRC
- Choosing an industrial controller
Choose the model and the I/O mix in the Raspberry PLC family.