LearnSCADA

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=True does.
  • 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.

A server's three nested layers. Four data blocks, di, co, ir and hr, appear first. A device context forms around them, then a server context around that. The server context is passed to StartTcpServer, and a request travels in to the holding-register block and a reply travels back out. Server context ModbusServerContext Device context ModbusSlaveContext · one device di discreteinputs bits co coils bits ir inputregisters 16-bit hr holdingregisters 16-bit StartTcpServer (context=ctx) listens on :5020
A server's three nested layers: four data blocks inside a device context, inside a server context, which is passed to the function that starts listening for requests.

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:

PYTHON — the four data blocks
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:

PYTHON — a sparse block
from pymodbus.datastore import (
    ModbusSparseDataBlock)

hr = ModbusSparseDataBlock({0: 100,
                            1000: 200,
                            40000: 300})
Sequential versus sparse. For a register map with values at addresses 0, 1000 and 40000, a sequential block fills every slot from 0 to 40000, allocating 40,001 slots to use 3. A sparse block stores only the three defined addresses. Sequential block every slot from the start address 0100040000 ≈ (not to scale) allocated: 40,001 slots · used: 3 Sparse block only the addresses you define 100 200 300 0100040000 allocated: 3 slots
A sequential block fills every address from its start, wasting space when the map is scattered. A sparse block stores only the addresses you define, which suits register maps with large gaps.

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:

PYTHON — the device context
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.

What zero_mode changes. A block holds 100, 200, 300, 400, 0, 0 in slots 0 to 5. With zero_mode=True, a read of address 0 lands in slot 0 and returns 100. With zero_mode off, the library adds one, so address 0 lands in slot 1 and returns 200: off by one. zero_mode=True address N → slot N read address 0 100 200 300 400 0 0 slot 0slot 1slot 2slot 3slot 4slot 5 returns 100 ✓ zero_mode off (legacy) library adds 1 → slot N + 1 read address 0 100 200 300 400 0 0 slot 0slot 1slot 2slot 3slot 4slot 5 returns 200 ✗ off by one
With 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.
Version reminder

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:

PYTHON — start the server
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
PYTHON — 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 write takes effect in the datastore. The client writes 1234 to holding register 10; the slot changes from 0 to 1234 and the server echoes the write. Later the client reads holding register 10 and gets 1234 back. Client your program HOLDING REGISTERS (hr) 0 0 0 1234 0 0 89101112 ① write_register(10, 1234) ② later: read_holding_registers(10, count=1) write 10 = 1234 echo: OK read 10 [1234] the value persisted
A write takes effect in the datastore. The server updates the block; a later read returns the new value, so the simulated device remembers its state like a real one.

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:

PYTHON — a live-updating server
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.

A background thread brings the server to life. Along the bottom, the updater thread loops every two seconds: sleep 2 s, value plus 1, setValues(4, 0, [value]). Input register 0 in the middle steps from 0 to 201, 202, 203 and 204. Along the top, the main thread runs StartTcpServer and answers a client that polls input register 0 and sees the value climb. Main thread StartTcpServer(...) blocks here, answering every request INPUT REG 0 0 201 202 203 204 Client polls reads 0 reads 201 reads 202 reads 203 reads 204 Updater thread (daemon, loops forever) time.sleep(2) value += 1 setValues(4, 0, [value])
A background thread brings the server to life. While the main thread serves requests, the updater writes a new value every two seconds, so a client polling that register sees it change as a real instrument would.

Here's a small client to watch it. It's the Chapter 11 pattern with a loop, reading input register 0 every two seconds:

PYTHON — watch_client.py
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
PYTHON — 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:

PYTHON — two devices, one server
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.