Part III · Chapter 15
A Monitoring and Control Project
Time to build something whole. This chapter assembles everything from Part III (client, server, decoding, the polling loop) into one small, complete application: a monitor for a pump station that reads, decodes, displays, and sends commands back to hold a tank near a target level.
By the end of this chapter you can
- Read a register map that spans all four tables and mixes data types.
- Build a simulated device whose data changes in response to your writes.
- Write a polling loop that reads, decodes, decides, and writes a command back.
- Explain hysteresis, and why control code needs safety logic.
What we are building
Our imaginary device is a pump station that keeps a tank near a desired level. It exposes a handful of points across the four tables: tank level and flow rate as measurements, a high-level alarm as a status bit, and a pump run command as a writable coil.
The program reads those points on a schedule, shows them, and switches the pump on when the level drops below a setpoint and off when it rises above: simple closed-loop control. We'll run a simulated station so you can build it all in software, but every line would work unchanged against real hardware. This is the shape of a great deal of real industrial software.
The register map
Every project begins with the register map, and ours is small. Notice the spread across all four tables and the mix of types (a scaled integer, a two-register float, and bits), which is exactly why Chapter 14's decoding work was worth doing:
| Point | Table / address | Type | Read with |
|---|---|---|---|
| Tank level | Input register 0 | Integer, ÷ 10, percent | FC04 |
| Flow rate | Input registers 1–2 | Float (two registers) | FC04 |
| High-level alarm | Discrete input 0 | Bit | FC02 |
| Pump run command | Coil 0 | Bit (read/write) | FC01 / FC05 |
| Level setpoint | Holding register 0 | Integer, ÷ 10, percent | FC03 |
Figure 15.2 · The pump station's register map. Level and flow sit side by side in input registers 0–2, so one FC04 request for three registers fetches both. (The listings below keep the setpoint in a Python constant; reading it from holding register 0 instead is a natural extension.)
The simulated station
First we build the device to monitor, extending the Chapter 12 server with a simulation that behaves like a real tank: the level rises while the pump runs and falls while it's off, and the alarm trips if the tank gets too full. Save this as station_server.py. The data blocks and contexts are exactly as Chapter 12 described:
import threading, time, struct
from pymodbus.server import StartTcpServer
from pymodbus.datastore import (
ModbusSequentialDataBlock,
ModbusSlaveContext, ModbusServerContext)
di = ModbusSequentialDataBlock(0, [0] * 10)
co = ModbusSequentialDataBlock(0, [0] * 10)
ir = ModbusSequentialDataBlock(0, [0] * 10)
hr = ModbusSequentialDataBlock(0, [0] * 10)
dev = ModbusSlaveContext(di=di, co=co, ir=ir,
hr=hr, zero_mode=True)
ctx = ModbusServerContext(slaves=dev, single=True)Only the background thread is new. It reads the pump coil the monitor will write, moves the level accordingly, computes the alarm, and stores level, flow (packed as a float across two registers), and alarm back into the datastore: a compact model of real plant behavior.
def sim(context):
level = 500 # 50.0 %, scaled x10
while True:
time.sleep(2)
pump = context[0].getValues(1, 0, 1)[0]
level += 30 if pump else -20
level = max(0, min(1000, level))
alarm = 1 if level > 900 else 0
fb = struct.pack(">f", 12.5)
hi, lo = struct.unpack(">HH", fb)
context[0].setValues(4, 0, [level])
context[0].setValues(4, 1, [hi, lo])
context[0].setValues(2, 0, [alarm])
threading.Thread(target=sim, args=(ctx,),
daemon=True).start()
StartTcpServer(context=ctx,
address=("0.0.0.0", 5020))context[0].getValues(1, 0, 1)[0] reads coil 0 (function code 1) to see whether the monitor has commanded the pump on. The three setValues calls write level and flow into the input registers (function code 4) and the alarm into the discrete inputs (function code 2). So every two seconds the level moves +3.0 % with the pump on or −2.0 % with it off, clamped to 0–100 %, and the alarm is set above 90 %. Run this server in its own terminal: it's now a living pump station waiting to be watched.
The monitor
Now the application itself. Save it as monitor.py. It opens with the float decoder from Chapter 14 (high word first, the station's convention) and a connection, guarded against failure:
import struct, time
from pymodbus.client import ModbusTcpClient
def regs_to_float(regs):
raw = struct.pack(">HH", regs[0], regs[1])
return struct.unpack(">f", raw)[0]
SETPOINT = 60.0 # percent
client = ModbusTcpClient("127.0.0.1", port=5020)
if not client.connect():
raise SystemExit("Cannot reach station")The heart is the polling loop. Each cycle it reads the three input registers, the alarm bit, and the pump coil; checks for errors as Chapter 11 taught; decodes the scaled level and the float flow; applies the control rule; and prints a one-line dashboard before sleeping until the next cycle:
while True:
ir = client.read_input_registers(0, count=3)
di = client.read_discrete_inputs(0, count=1)
co = client.read_coils(0, count=1)
if ir.isError() or di.isError() or co.isError():
print("read error; retrying")
time.sleep(2)
continue
level = ir.registers[0] / 10
flow = regs_to_float(ir.registers[1:3])
alarm = di.bits[0]
pump = co.bits[0]
if level < SETPOINT and not pump:
client.write_coil(0, True)
pump = True
elif level >= SETPOINT and pump:
client.write_coil(0, False)
pump = False
print("Level %.1f%% Flow %.1f "
"Pump %s Alarm %s" % (level, flow,
"ON" if pump else "off",
"!" if alarm else "-"))
time.sleep(2)Three reads, one per table it needs (FC04, FC02, FC01), and a write (FC05) only when the pump must change state. Note that the monitor writes only on a change: it checks not pump before switching on and pump before switching off, so it doesn't send a redundant command every cycle.
Watching it run
With the station server running in one terminal, start the monitor in another. The readout updates every two seconds, the level climbing while the pump runs and easing back after it switches off as the tank crosses the setpoint:
Level 53.0% Flow 12.5 Pump ON Alarm -
Level 56.0% Flow 12.5 Pump ON Alarm -
Level 59.0% Flow 12.5 Pump ON Alarm -
Level 62.0% Flow 12.5 Pump off Alarm -
Level 60.0% Flow 12.5 Pump off Alarm -
Level 58.0% Flow 12.5 Pump ON Alarm -Read across those lines. The level rises while the pump runs; once it climbs past the 60 % setpoint, the monitor writes the coil to switch the pump off, and the level begins to fall; when it drops back below, the pump comes on again. The Pump column shows the state after that cycle's decision, so the line where the level crosses the setpoint already reads off (or ON). Your program is holding the tank near 60 %, on its own, by reading and writing Modbus: the essence of supervisory control.
Your first line or two may differ (the two programs sleep independently, so where the monitor catches the station's cycle varies), but the rhythm is the same: +3.0 per cycle with the pump on, −2.0 with it off. The simulator below runs the same code paths in your browser so you can watch it, and break it, without a microcomputer.
Pump-station simulator
A line-for-line port of sim() and the monitor.py loop. Each cycle the monitor polls and decides, then the station moves the level (+3.0 % pump on, −2.0 % off, clamped 0–100, alarm above 90 %). The station starts at 50.0 %.
Modbus traffic, latest cycle
Things to try: run at the book's setting and count how often the pump switches; tick Hysteresis and watch the cycle slow and widen; set SETPOINT to 95, drag the level to 92 %, and compare the alarm with and without Alarm forces pump off.
The control rule, and a caution
The control logic here is the simplest possible: pump on below the setpoint, off above it. With the level hovering right at the setpoint, that rule makes the pump chatter, switching on and off almost every cycle. A real system adds a small gap between the on and off thresholds, a technique called hysteresis.
You'd also think hard about safety, because writing commands to physical equipment is consequential. A monitor that only reads is harmless; one that writes can start pumps and open valves. Real control code needs careful limits, alarms, and fail-safe behavior when communication drops.
That caution is the real lesson of this project. The mechanics of Modbus control are easy: you've just written them. The hard, important parts are everything around them: how often to poll, what to do when a read fails, how to keep a stalled device from freezing the loop, and how to make writes safe. Those are the subject of Part IV, which turns from "how do I make Modbus work" to "how do I make a Modbus system work well and safely."
Check your understanding
1. Which function code does the monitor use to read the high-level alarm?
The alarm is discrete input 0, a read-only bit, so it's read with read_discrete_inputs, function code 2.
2. Input register 0 reads 535. What level does the monitor print?
The map says level is an integer ÷ 10, so the monitor computes ir.registers[0] / 10 = 53.5.
3. The monitor reads 62.0 % with the pump running and SETPOINT 60. What does it send?
level >= SETPOINT and pump is true, so it calls client.write_coil(0, False): Write Single Coil with 0000.
4. Why add hysteresis to the control rule?
A gap between the on and off thresholds means the level must travel across the band before the pump switches again, giving a slower, calmer cycle.