Modbus RTU Simulator in Python: a step-by-step tutorial for SCADA integrators
Practical tutorial for building a Modbus RTU simulator in Python and validating SCADA integration with the AEM-60DC8 before the hardware ships.
The moment a customer signs off on the AEM-60DC8 purchase order, the project clock starts ticking. The SCADA engineering team needs to set up the driver, map tags, build screens and run edge-case tests — except the physical meter is still on the production line. The practical answer is to simulate the Modbus RTU device in Python and expose a server that responds as if the real multimeter were attached. This tutorial walks through standing up that simulator from scratch, organizing a 147 holding registers map compatible with AEM-60DC8 firmware v1.03, validating it with bench clients, and plugging it into your SCADA through a virtual COM port. You will leave with code that runs, not slides.
Why simulate before the meter arrives
Waiting for hardware before starting integration is an expensive choice. Every parked day on site is a commissioner day rate, a field engineer hour and a shutdown window that does not come back. Pre-staging with a simulator solves three concrete problems:
- Time savings. Driver setup, tag creation, alarms and historians happen in parallel with manufacturing. When the AEM-60DC8 lands, 90% of the SCADA project is already validated.
- Cleaner deployment. Addressing mistakes, scaling errors and byte-order issues surface in the lab, not on an energized panel at 3 a.m.
- Edge-case testing. Triggering an overvoltage alarm, a comms failure or an out-of-range value is trivial in software. On hardware, it needs a bench, a programmable source and time.
There is a less obvious gain: documentation. The simulator becomes a living reference of the register map. New team members read code, not stale PDFs.
Python libraries: a short comparison
Three libraries dominate the Python Modbus space. The comparison is straightforward:
| Library | Strengths | Limits | When to use |
|---|---|---|---|
| pymodbus | Active, async-friendly, supports RTU/TCP/ASCII, pluggable datastore | API changes between major versions | Default for any new project |
| modbus-tk | Stable, simple, lightweight | Sporadic maintenance, no async | Legacy short scripts |
| libmodbus (binding) | Native C, fast | Annoying Windows setup, less Pythonic | Bridging to existing C/C++ code |
For this tutorial, pymodbus 3.6.x. It covers RTU, TCP and ASCII, has a customizable datastore (key for dynamic registers) and runs on Windows, Linux and macOS. Pin the minor version — the API moved between 2.x and 3.x and will move again in 4.x.
Installation and environment
Create a virtualenv. Mixing Modbus dependencies into the system Python is a recipe for conflict.
python -m venv .venv
# Windows
.venv\Scripts\activate
# Linux/macOS
source .venv/bin/activate
pip install --upgrade pip
pip install "pymodbus[serial]==3.6.9"
The [serial] extra pulls in pyserial, required for RTU. Confirm by running python -c "import pymodbus, serial; print(pymodbus.__version__, serial.__version__)" — you should see 3.6.9 and something like 3.5. If it breaks here, the wrong Python is on PATH; fix that before moving on.
On Windows, install com0com to create virtual COM port pairs (COM10 <-> COM11). On Linux, use socat to spawn two linked pty endpoints:
sudo apt install socat
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1
Keep that terminal open. ttyV0 belongs to the simulator; ttyV1 to the client or SCADA.
Hello world: a minimal Modbus RTU server
Save as server_min.py. The server listens on /tmp/ttyV0 (Linux) or COM10 (Windows), responds as slave 1, and holds 10 fixed holding registers.
# server_min.py
import logging
from pymodbus.datastore import ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext
from pymodbus.server import StartSerialServer
from pymodbus.framer import ModbusRtuFramer
logging.basicConfig(level=logging.INFO)
PORT = "/tmp/ttyV0" # Windows: "COM10"
# 10 holding registers, all starting at 0
hr_block = ModbusSequentialDataBlock(0, [0] * 10)
slave_ctx = ModbusSlaveContext(hr=hr_block, zero_mode=True)
server_ctx = ModbusServerContext(slaves={1: slave_ctx}, single=False)
if __name__ == "__main__":
# Preload a few values
hr_block.setValues(0, [2200, 2201, 2199, 2202, 2198, 2200, 2203, 2197, 0, 0])
StartSerialServer(
context=server_ctx,
framer=ModbusRtuFramer,
port=PORT,
baudrate=19200,
bytesize=8,
parity="E",
stopbits=1,
timeout=1,
)
Run it in a terminal with python server_min.py. You will see pymodbus logging that the server opened the port. If could not open port shows up, either the serial port name is wrong or socat died.
AEM-60DC8 register map: 147 holding registers
Simplified example. The map below is didactic and meant to validate the integration flow. The full official map for v1.03 ships with the AEM-60DC8 manual (LRI). Use this only as a skeleton.
Organize the 147 holding registers in logical blocks. Decimal addresses, zero_mode=True:
| Range | Content | Type |
|---|---|---|
| 0–7 | Voltage CH1–CH8 (V x 100) | uint16 |
| 8–15 | Current CH1–CH8 (mA) | uint16 |
| 16–23 | Power CH1–CH8 (W) | int16 |
| 24–39 | Cumulative energy CH1–CH8 | uint32 (pairs) |
| 40–47 | Digital status CH1–CH8 | uint16 (bitmap) |
| 48–63 | Alarms (overvoltage, undervoltage, etc.) | uint16 |
| 64–95 | Configuration (baudrate, slave ID, scales) | uint16 |
| 96–146 | Reserved / diagnostics | uint16 |
Save as aem_map.py:
# aem_map.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Block:
start: int
length: int
label: str
BLOCKS = (
Block(0, 8, "voltage_ch"),
Block(8, 8, "current_ch"),
Block(16, 8, "power_ch"),
Block(24, 16, "energy_ch"),
Block(40, 8, "status_ch"),
Block(48, 16, "alarms"),
Block(64, 32, "config"),
Block(96, 51, "diag"),
)
TOTAL_REGISTERS = sum(b.length for b in BLOCKS) # = 147
assert TOTAL_REGISTERS == 147, f"expected 147, got {TOTAL_REGISTERS}"
def initial_values() -> list[int]:
values = [0] * TOTAL_REGISTERS
# nominal voltages (220.0 V) -> 22000 with x100 scaling
for ch in range(8):
values[ch] = 22000
# default baudrate 19200 encoded as 3
values[64] = 3
# slave id
values[65] = 1
return values
Now a server that loads that map. Save as server_aem.py:
# server_aem.py
import logging
from pymodbus.datastore import ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext
from pymodbus.server import StartSerialServer
from pymodbus.framer import ModbusRtuFramer
from aem_map import initial_values, TOTAL_REGISTERS
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
PORT = "/tmp/ttyV0" # Windows: "COM10"
SLAVE_ID = 1
hr_block = ModbusSequentialDataBlock(0, initial_values())
slave_ctx = ModbusSlaveContext(hr=hr_block, zero_mode=True)
server_ctx = ModbusServerContext(slaves={SLAVE_ID: slave_ctx}, single=False)
if __name__ == "__main__":
logging.info("AEM-60DC8 v1.03 simulator | %d registers | %s", TOTAL_REGISTERS, PORT)
StartSerialServer(
context=server_ctx,
framer=ModbusRtuFramer,
port=PORT,
baudrate=19200,
bytesize=8,
parity="E",
stopbits=1,
timeout=1,
)
Validating with a Modbus client
Two approaches. First, the command line with mbpoll (cross-platform, installable via Chocolatey on Windows or apt on Linux):
# Read 8 holding registers starting at address 0, slave 1, 19200 8E1
mbpoll -m rtu -a 1 -b 19200 -P even -t 4 -r 1 -c 8 /tmp/ttyV1
Note: mbpoll uses 1-based addressing by default, hence -r 1 reads the register at address 0. You should see eight 22000 values.
Second, a Python client — useful for regression tests:
# client_check.py
from pymodbus.client import ModbusSerialClient
from pymodbus.framer import ModbusRtuFramer
client = ModbusSerialClient(
port="/tmp/ttyV1", # Windows: "COM11"
framer=ModbusRtuFramer,
baudrate=19200,
bytesize=8,
parity="E",
stopbits=1,
timeout=1,
)
if not client.connect():
raise SystemExit("failed to open client port")
rr = client.read_holding_registers(address=0, count=8, slave=1)
if rr.isError():
print("error:", rr)
else:
for i, v in enumerate(rr.registers):
print(f"CH{i+1}: {v/100:.2f} V")
client.close()
Expected output: eight CHn: 220.00 V lines. If a timeout appears, check the port pair and parity — 90% of RTU errors are parity or stop bit.
For GUI tools, qModMaster (Linux/Windows) and Modbus Poll (Windows) cover the same scenario and are useful when showing the map to the operations team.
Simulating dynamic behavior
Static registers prove that communication is up, but they do not exercise the SCADA. Make values move on a separate thread:
# server_dynamic.py
import logging
import threading
import time
import math
import random
from pymodbus.datastore import ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext
from pymodbus.server import StartSerialServer
from pymodbus.framer import ModbusRtuFramer
from aem_map import initial_values, TOTAL_REGISTERS
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
PORT = "/tmp/ttyV0"
SLAVE_ID = 1
hr_block = ModbusSequentialDataBlock(0, initial_values())
slave_ctx = ModbusSlaveContext(hr=hr_block, zero_mode=True)
server_ctx = ModbusServerContext(slaves={SLAVE_ID: slave_ctx}, single=False)
def simulate():
t0 = time.time()
while True:
t = time.time() - t0
for ch in range(8):
# 220 V +/- 2 V sinusoidal with small noise
v = 22000 + int(200 * math.sin(2 * math.pi * 0.1 * t + ch))
v += random.randint(-20, 20)
hr_block.setValues(ch, [v])
# overvoltage alarm on CH1 every 30 s
alarm = 1 if int(t) % 30 < 2 else 0
hr_block.setValues(48, [alarm])
time.sleep(0.5)
if __name__ == "__main__":
threading.Thread(target=simulate, daemon=True).start()
logging.info("Dynamic AEM-60DC8 simulator | %d regs", TOTAL_REGISTERS)
StartSerialServer(
context=server_ctx,
framer=ModbusRtuFramer,
port=PORT,
baudrate=19200,
bytesize=8,
parity="E",
stopbits=1,
timeout=1,
)
Now voltages drift gently and an alarm fires periodically. That is enough to exercise trending, deadbands, alarms and historian event capture.
Hooking it to your SCADA
The bridge is the virtual serial port. The simulator holds one end, the SCADA reads the other.
Windows with Ignition, Elipse E3 or AVEVA Edge. Install com0com, create the COM10 <-> COM11 pair. Point the Python server at COM10 and the SCADA Modbus RTU driver at COM11. Match parameters on both sides: 19200 8E1, slave ID 1, 1000 ms timeout. The baud rates supported by the AEM-60DC8 are 4800/9600/19200/38400/57600/115200 bps — use the same value the final project will run.
Linux. socat provides /tmp/ttyV0 and /tmp/ttyV1. Point the SCADA Modbus gateway at /tmp/ttyV1. It runs well with Ignition in a container.
Configure tags pointing at the simulator map, validate readings, write to configuration registers to ensure round-trip, and force alarms to test the notification tree. That cycle, done in the lab, saves days in the field.
Two pitfalls to keep in mind: byte order (v1.03 uses big-endian on uint32, standard Modbus) and timeout — the real AEM-60DC8 answers within 50 ms, so the simulator is faster than the hardware. Do not tune timeout against the simulator.
Next steps
This tutorial covers the skeleton. In production you will want to simulate CRC failures, variable latency, intermittent packet loss and slave-busy responses — all real events on long RS-485 buses.
LRI maintains the official AEM-60DC8 simulator as an open-source project. It ships with the full v1.03 map, fault profiles, a record-and-replay mode for traffic captured on site, and integration examples for Ignition and Elipse. Grab it at github.com/lri-engenharia/aem60dc8-simulator (Secure by Design by default: the simulator does not expose network ports without explicit configuration).
FAQ
1. Can I run the simulator with no serial hardware at all?
Yes. On Linux, socat creates pty pairs entirely in software. On Windows, com0com does the same with COM ports. No cable, USB-RS485 converter or adapter is needed in the lab.
2. Which pymodbus version should I pin?
Pin pymodbus[serial]==3.6.9. The API changed between 2.x and 3.x; mixing old tutorials with the new version is the most common source of error in new projects.
3. Can the simulator be used for certification? No. Use it for integration, SCADA parameterization and functional tests. Metrological and protocol certification require real hardware and an accredited lab.
4. How do I simulate a CRC failure? In pymodbus 3.x you need a custom framer that corrupts the last byte of the response with a configurable probability. The official LRI simulator ships that mode ready.
5. Does it work with Modbus TCP too?
Yes. Swap StartSerialServer for StartTcpServer and ModbusRtuFramer for ModbusSocketFramer. The datastore and the AEM-60DC8 map remain identical.