Skip to Content

How to use PyModbus with a Raspberry Pi PLC: async examples

Modbus TCP and RTU from Python on a Raspberry PLC: install, asyncio server and client, tested with pymodbus 3.16
September 25, 2023 by
How to use PyModbus with a Raspberry Pi PLC: async examples
Boot & Work Corp. S.L, Ricard Franch Argullol

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 PLCpymodbus classTypical use
Modbus TCP client (master)ModbusTcpClient, AsyncModbusTcpClientRead energy meters, drives and remote I/O on Ethernet
Modbus RTU client (master)ModbusSerialClient, AsyncModbusSerialClientPoll slaves on the RS-485 line
Modbus TCP server (slave)StartTcpServer, StartAsyncTcpServerGive PLC values to a SCADA, an HMI or another PLC
Modbus RTU server (slave)StartSerialServer, StartAsyncSerialServerAnswer 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.

SynchronousAsynchronous (asyncio)
How a request waitsThe call blocks until the answer or the timeoutThe call is awaited, and other tasks run while it waits
Several devicesOne after the other: a dead device delays all the others by its timeoutOne task per device: a dead device delays only its own task
Client and server in one programNeeds threads and locksTasks in the same event loop
CodeShorter, easier to debugasync and await in every function that talks Modbus
Good forA script that reads one device, tests, cron jobsGateways, 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 codeError with 3.16.1Code now
from pymodbus.transaction import ModbusSocketFramerImportErrorfrom pymodbus.framer import FramerType, then framer=FramerType.SOCKET or FramerType.RTU
ModbusSlaveContext(di=..., co=..., hr=..., ir=...)ImportErrorSimDevice(1, simdata=(...)) with SimData
client.write_coil(0, a, 0x01)TypeError: takes 3 positional argumentsclient.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.

Open the Modbus simulatorRead the data gateway case study

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.

​Search in our Blog

How to use PyModbus with a Raspberry Pi PLC: async examples
Boot & Work Corp. S.L, Ricard Franch Argullol September 25, 2023
Share this post
Tags

Looking for your ideal Programmable Logic Controller?

Take a look at this product comparison with other industrial controllers Arduino-based. 

We are comparing inputs, outputs, communications and other features with the ones of the relevant brands.

PLC Comparison