Part III · Chapter 12
Building a Modbus Server
In Chapter 10 you ran a practice server without looking inside; in Chapter 11 you read from it. Now you open it up, understand every line, and learn to build a server that simulates any device you like.
By the end of this chapter you can
- Describe a server's three nested layers: data blocks, device context, server context.
- Choose between sequential and sparse data blocks, and explain what
zero_mode=Truedoes. - Start a TCP server and explain why writes persist without any code of yours.
- Make values change with a background thread, and simulate several unit IDs from one server.
A server you control is the ideal test partner. You can develop and debug client code with no hardware, reproduce a real device's register map for offline testing, and, important for the chapters ahead, give your write commands somewhere safe to land.
What a server is, in code
A Modbus server is simpler than you might expect, because the library does all the framing and responding. Your job is to describe the device's data and start the server; pymodbus listens and answers out of that data automatically. The structure has three nested layers. At the center are the data blocks, one per table. They're gathered into a device context representing one device. One or more device contexts live in a server context, which is what you hand to the function that starts listening.
The four data blocks
Each of Chapter 2's four tables (discrete inputs, coils, input registers, holding registers) is represented by a data block: a numbered run of storage slots. The most common kind is the sequential data block, created from a starting address and a list of initial values. Here are all four, each with one hundred slots set to zero:
from pymodbus.datastore import (
ModbusSequentialDataBlock,
ModbusSlaveContext, ModbusServerContext)
di = ModbusSequentialDataBlock(0, [0] * 100)
co = ModbusSequentialDataBlock(0, [0] * 100)
ir = ModbusSequentialDataBlock(0, [0] * 100)
hr = ModbusSequentialDataBlock(0, [0] * 100)The names are the conventional abbreviations: di discrete inputs, co coils, ir input registers, hr holding registers. Each ModbusSequentialDataBlock(0, [0] * 100) starts at address 0 and holds one hundred zeros. Discrete inputs and coils hold bits; input and holding registers hold 16-bit numbers. Together they are the device's entire data. To simulate a real device you'd seed them with meaningful values, as the practice server seeded a temperature into the input registers.
Sequential and sparse blocks
A sequential block is the right default, but it has a cost. It's one continuous run from its starting address, so a block that must reach address 5000 allocates all five thousand slots beneath it, even if only a handful are used. When a register map is scattered (a value at 0, another at 1000, another at 40000), a sparse data block is more economical: it stores only the addresses you define, given as a dictionary of address → value:
from pymodbus.datastore import (
ModbusSparseDataBlock)
hr = ModbusSparseDataBlock({0: 100,
1000: 200,
40000: 300})The trade-off is simple: sequential blocks are simplest and fastest for dense, low-numbered maps; sparse blocks save memory when addresses are few and far apart. For nearly all practice and most real simulations, the sequential block is the one you want, and it's what the rest of this chapter uses.
The device context
The four blocks are gathered into a device context, a ModbusSlaveContext, representing one complete device. This is where the four tables become a single addressable unit, and where you set the addressing mode:
dev = ModbusSlaveContext(di=di, co=co,
ir=ir, hr=hr,
zero_mode=True)The four keyword arguments attach the four blocks. zero_mode=True deserves a word, because it controls exactly the addressing behavior Chapter 2 warned about. With zero_mode=True, the client's address indexes the block directly: address 0 reads slot 0, address 5 reads slot 5. Without it, the library applies the older one-based convention (address 1 means the first register) by adding one to every incoming address, which reintroduces the off-by-one that confuses so many people. zero_mode=True makes the server behave the clean, zero-based way the modern protocol specifies, and it's why the practice server's values landed exactly where Chapter 11 read them.
zero_mode=True a client address maps straight to the same slot. Without it the library shifts every address by one, so a block that starts at 0 answers one slot over.These names are the pymodbus 3.6/3.7 interface used throughout the book and pinned at 3.7.4 in Chapter 10. In later releases zero_mode was removed (3.8), and ModbusSlaveContext, slaves= and the client's slave= became ModbusDeviceContext, devices= and device_id= (3.10). On 3.7.4, the spellings shown here are correct.
The server context and starting up
Finally the device context goes into a server context, which is handed to StartTcpServer. For a server simulating one device, single=True tells the library that one device answers every request regardless of unit address, which is convenient for practice:
from pymodbus.server import StartTcpServer
ctx = ModbusServerContext(slaves=dev,
single=True)
print("Server listening on 5020 ...")
StartTcpServer(context=ctx,
address=("0.0.0.0", 5020))The address ("0.0.0.0", 5020) means "listen on all of this machine's network interfaces, on port 5020," so the server is reachable from localhost and from other machines on your network. StartTcpServer then blocks, running until you stop it with Ctrl-C. From here the server answers reads and writes on its own. You wrote no code for any specific function code: the library routes each incoming request to the right data block automatically.
Show the whole file: server.py
from pymodbus.server import StartTcpServer
from pymodbus.datastore import (
ModbusSequentialDataBlock,
ModbusSlaveContext, ModbusServerContext)
di = ModbusSequentialDataBlock(0, [0] * 100)
co = ModbusSequentialDataBlock(0, [0] * 100)
ir = ModbusSequentialDataBlock(0, [0] * 100)
hr = ModbusSequentialDataBlock(0, [0] * 100)
dev = ModbusSlaveContext(di=di, co=co,
ir=ir, hr=hr,
zero_mode=True)
ctx = ModbusServerContext(slaves=dev,
single=True)
print("Server listening on 5020 ...")
StartTcpServer(context=ctx,
address=("0.0.0.0", 5020))Stop the practice server first (Ctrl-C in its terminal): both use port 5020, and only one program can listen on a port at a time.
Writes land here
This is the part that matters for the chapters ahead. When a client writes (sets a coil, changes a holding register), the server updates the matching data block, and the new value persists. A later read of that address returns what was written. You don't code this; it's automatic. That makes your server a faithful stand-in for a real device: command a coil on and it stays on; write a setpoint and it sticks. The writes you'll send in later chapters need somewhere to take effect, and this datastore is that place.
A device that comes alive
A static server is useful, but real devices change: a temperature drifts, a counter climbs. You can simulate that with a small background thread that updates a register every couple of seconds while the server runs, which is far better for testing a client meant to track live data. The updater reaches into the server context and writes a new value, using the function code to pick the table: 4 for input registers, the read-only measurements a sensor would produce. Add this to the end of your server, in place of the final StartTcpServer call:
import threading, time
def updater(context):
value = 200
while True:
time.sleep(2)
value += 1
context[0].setValues(4, 0, [value])
print("input reg 0 ->", value)
t = threading.Thread(target=updater,
args=(ctx,), daemon=True)
t.start()
StartTcpServer(context=ctx,
address=("0.0.0.0", 5020))The key line is context[0].setValues(4, 0, [value]). It reaches into the device context, selects the input-register table with function code 4, and writes the new value at address 0. Started as a daemon thread before StartTcpServer blocks, the updater runs alongside the server, nudging input register 0 upward every two seconds (201, 202, 203…; the register itself reads 0 until the first update). Point a Chapter 11-style client at it, read input register 0 a few times, and watch the number climb, a small but convincing imitation of a live sensor.
Here's a small client to watch it. It's the Chapter 11 pattern with a loop, reading input register 0 every two seconds:
import time
from pymodbus.client import ModbusTcpClient
client = ModbusTcpClient("127.0.0.1", port=5020)
if not client.connect():
print("Could not connect — is the server up?")
raise SystemExit
for _ in range(8):
ri = client.read_input_registers(0, count=1)
if not ri.isError():
print("Input reg 0:", ri.registers[0])
time.sleep(2)
client.close()Show the whole file: live_server.py
import threading, time
from pymodbus.server import StartTcpServer
from pymodbus.datastore import (
ModbusSequentialDataBlock,
ModbusSlaveContext, ModbusServerContext)
di = ModbusSequentialDataBlock(0, [0] * 100)
co = ModbusSequentialDataBlock(0, [0] * 100)
ir = ModbusSequentialDataBlock(0, [0] * 100)
hr = ModbusSequentialDataBlock(0, [0] * 100)
dev = ModbusSlaveContext(di=di, co=co,
ir=ir, hr=hr,
zero_mode=True)
ctx = ModbusServerContext(slaves=dev,
single=True)
def updater(context):
value = 200
while True:
time.sleep(2)
value += 1
context[0].setValues(4, 0, [value])
print("input reg 0 ->", value)
t = threading.Thread(target=updater,
args=(ctx,), daemon=True)
t.start()
print("Server listening on 5020 ...")
StartTcpServer(context=ctx,
address=("0.0.0.0", 5020))Simulated terminals: a live server and a polling client
A replay of the two-terminal run. Time is compressed: each two-second tick is shown as about 0.7 s.
Terminal 1 — server
Press Run.Terminal 2 — client
Simulating several devices
One last capability rounds out the picture. Pass single=False and a dictionary of device contexts keyed by unit address, and one server simulates several devices at once, each answering to its own unit ID. That's useful for reproducing a whole multidrop bus or testing a client that polls many units. Build several ModbusSlaveContext objects and map each to an address:
dev1 = ModbusSlaveContext(
hr=ModbusSequentialDataBlock(0, [111] * 10),
zero_mode=True)
dev2 = ModbusSlaveContext(
hr=ModbusSequentialDataBlock(0, [222] * 10),
zero_mode=True)
ctx = ModbusServerContext(slaves={1: dev1, 2: dev2},
single=False)A client now has to name the unit, using the slave= keyword from Chapter 11 (correct for the pinned 3.7.4): client.read_holding_registers(0, count=1, slave=2) reads from dev2 and returns [222], while slave=1 reaches dev1 and returns [111]. Ask for a unit that isn't in the dictionary (slave=3) and pymodbus answers with exception 0B, Gateway Target Device Failed to Respond, as if it were a gateway with nothing behind that unit ID. Most of the time single=True is all you need, but it's handy to know this door exists when you want to mimic a realistic system rather than a lone device.
You can now both read and create Modbus devices in software. So far every exchange has crossed Ethernet over localhost. Chapter 13 takes the same skills to the wire that defines industrial Modbus, RS-485, using a USB adapter to talk RTU to real or simulated serial devices.
Check your understanding
1. Which nesting is correct for a pymodbus server?
Blocks (one per table) make up a device context; one or more device contexts make up the server context you pass to StartTcpServer.
2. With zero_mode=True, a client reads address 5. Which slot answers?
Zero mode uses the address directly: address N maps to slot N, with no legacy off-by-one.
3. A device's register map uses addresses 0, 1000 and 40000 only. Which block suits it?
A sequential block would allocate 40,001 slots to use 3. A sparse block stores only the addresses you define.
4. A client writes 1234 to holding register 10, then reads holding register 10. What comes back, and what code made that happen?
Writes persist in the data block with no code of yours, which is what makes the server a faithful stand-in for a real device.