LearnSCADA

Part III · Chapter 11

Your First Modbus TCP Client

This is where Modbus stops being theory. A dozen lines of Python connect to the practice server, read its registers, check for an exception, and print real values, and every line maps to something you already understand.

By the end of this chapter you can

  • Name the five steps every client program follows.
  • Connect with ModbusTcpClient and read holding and input registers.
  • Check isError() before trusting data, and scale raw integers into real readings.
  • Pass a unit address and guard against a failed connection.

Before you start, make sure practice_server.py from Chapter 10 is still running in its own terminal, and open a second terminal (with the modbus-lab environment activated) for the client.

The shape of a client

Almost every Modbus client, however large, has the same five-step shape: connect to the server, read or write using a function code, check whether the response was an error, use the data, and close the connection. Keep that pattern in mind and the code never feels mysterious, because each line is one of those steps. pymodbus hides all of Part II's frame-building and byte-order detail inside simple method calls: you say what you want, it handles how.

The five-step shape of a client. A highlight moves down five rows in turn: 1 connect, client.connect(); 2 read or write, client.read_holding_registers(0, count=4); 3 check for an error, rr.isError(); 4 use the data, rr.registers; 5 close, client.close(). 1 Connect client.connect() 2 Read or write rr = client.read_holding_registers(0, count=4) 3 Check for an error if rr.isError(): ... 4 Use the data print(rr.registers) 5 Close client.close()
The five-step shape shared by nearly every client program: connect, read or write, check for an error, use the data, and close the connection.

Connecting

Create a file called read_client.py. The first lines import the library and open a connection. The server runs on the same board, so the address is 127.0.0.1, the standard name for "this machine" (localhost), and the port is the 5020 chosen in Chapter 10:

PYTHON — connect to the server
from pymodbus.client import ModbusTcpClient
client = ModbusTcpClient("127.0.0.1", port=5020)
client.connect()

The ModbusTcpClient object represents the connection; connect() actually opens it. Against a real device on your network, only the address changes: its IP in place of 127.0.0.1, and usually the real port 502 in place of 5020. Everything else stays identical, which is the quiet beauty of the library: localhost practice and field work use the same code.

Reading holding registers

Now the heart of the program: one call reads four holding registers starting at address 0. That's function code 0x03 from Chapter 8, but you don't need to remember the number, because the method is named for what it does:

PYTHON — read four holding registers
rr = client.read_holding_registers(0, count=4)
if rr.isError():
    print("Read error:", rr)
else:
    print("Holding 0-3:", rr.registers)

In that one call pymodbus does everything Part II described. It builds the PDU (function 0x03, start 0, quantity 4), wraps it in an MBAP header, sends the frame over TCP, waits for the reply, and unpacks the returned bytes, two per register, back into ordinary numbers.

MBAP header Function code Data
What read_holding_registers(0, count=4) does. Step 1 builds the PDU 03 00 00 00 04. Step 2 wraps it in the MBAP header 00 01 00 00 00 06 01. Step 3 sends it over TCP and waits. Step 4 receives the response 00 01 00 00 00 0B 01 03 08 00 64 00 C8 01 2C 01 90. Step 5 unpacks the data bytes in pairs into the list 100, 200, 300, 400. client.read_holding_registers(0, count=4) ① Build the PDU 03 00 00 00 04 funcstart 0qty 4 ② Wrap it in the MBAP header 00 01 00 00 00 06 01 03 00 00 00 04 trans IDprotocollength 6unitthe same PDU ③ Send over TCP, wait for the reply client server 127.0.0.1:5020 ④ Receive the response 00 01 00 00 00 0B 01 03 08 00 64 00 C8 01 2C 01 90 MBAP · length 11func8 bytes 100200300400 ⑤ Unpack, two bytes per register rr.registers = [100, 200, 300, 400]
One read_holding_registers call does all of Part II for you: it builds the PDU, wraps it for TCP, sends it, waits for the reply, and unpacks the register bytes back into a list of numbers (0x0064 = 100, 0x00C8 = 200, 0x012C = 300, 0x0190 = 400).

What comes back is an object holding the result, and you ask it two questions. isError() tells you whether the server replied with an exception; registers is the plain list of integers when it didn't.

That isError() check isn't optional politeness. It's the code-level form of Chapter 3's three outcomes. Data came back: isError() is false and registers holds your values. An exception came back: isError() is true and the object describes the fault. The third outcome, silence, shows up separately as a failed connection or a timeout, handled below. Always check before you trust the data.

Chapter 3 outcomeWhat your code sees
Normal responserr.isError() is False; rr.registers is the list of values
Exceptionrr.isError() is True; printing rr describes the fault
SilenceNo usable result: connect() returns False, or the request times out

Reading input registers and scaling

Now read the two input registers the practice server seeded with a temperature and a humidity. read_input_registers (function 0x04) is otherwise identical. The interesting part is what happens to the raw numbers afterward. As Chapter 2 showed, devices often store a fractional value as a scaled integer: the server holds 235 to mean 23.5 degrees, so the client divides by ten to recover the reading:

PYTHON — read and scale a measurement
ri = client.read_input_registers(0, count=2)
if not ri.isError():
    temp = ri.registers[0] / 10
    hum = ri.registers[1] / 10
    print("Temp: %.1f C" % temp)
    print("Humidity: %.1f %%" % hum)

client.close()

The last line, client.close(), releases the TCP connection cleanly. The scaling step is where Chapter 2's lesson becomes concrete: Modbus delivered a faithful integer, and you supplied the meaning, exactly as the register map dictates. The protocol moved a number; turning it into a temperature was your job.

Scaling raw registers. The raw integer 235 from input register 0 passes through a divide-by-ten step and becomes 23.5 degrees C. The raw integer 567 from input register 1 becomes 56.7 percent. Register map: both values are stored × 10 input register 0 · raw input register 1 · raw ÷ 10 ÷ 10 temperature humidity 235 567 23.5 °C 56.7 % Modbus moved the integers. The meaning came from the register map.
The client recovers a real reading from a raw register. The server sends the integer 235; dividing by ten yields 23.5 degrees. The scaling comes from the register map, not from Modbus.

Running it

Put the pieces together and read_client.py is complete:

PYTHON — read_client.py
from pymodbus.client import ModbusTcpClient
client = ModbusTcpClient("127.0.0.1", port=5020)
client.connect()

rr = client.read_holding_registers(0, count=4)
if rr.isError():
    print("Read error:", rr)
else:
    print("Holding 0-3:", rr.registers)

ri = client.read_input_registers(0, count=2)
if not ri.isError():
    temp = ri.registers[0] / 10
    hum = ri.registers[1] / 10
    print("Temp: %.1f C" % temp)
    print("Humidity: %.1f %%" % hum)

client.close()

Save it and run it in your second terminal, with the practice server still running in the first:

TERMINAL — run the client
python read_client.py
OUTPUT
Holding 0-3: [100, 200, 300, 400]
Temp: 23.5 C
Humidity: 56.7 %

Simulated terminal: run the client

You can't run Python in the browser, so this replays the real output (recorded with pymodbus 3.7.4 against the practice server). Try the normal run, then the variations from this chapter.

Choose a run above.

Take a moment with this. Those numbers made a full round trip. Your client built a request frame and sent it across a TCP connection; the server looked up the values in its register tables and built a response frame; your client unpacked it into the list you see. Every concept from Parts I and II just executed, in milliseconds, on your desk. The 100, 200, 300, 400 are the seeded holding registers; 23.5 and 56.7 are the input registers, scaled.

Two practical notes

1. The unit address

The practice server answers regardless of unit address, but a real device, or one behind a gateway, expects you to name it. In pymodbus you pass the unit address as an extra keyword on the read call. The keyword has changed across library versions: older releases use slave=1, newer ones device_id=1. If a device ignores you, check which one your version wants. For the practice server you can simply leave it out.

PYTHON — naming the unit (pymodbus 3.7.4)
rr = client.read_holding_registers(0, count=4, slave=1)
Which keyword for this course?

With the pymodbus 3.7.4 pinned in Chapter 10, slave= is correct and was verified working. Releases from 3.10 onward renamed it to device_id=, so code you find online may use that spelling. It won't work on 3.7.4.

2. Failure to connect

If the server isn't running, or you aim at the wrong address, connect() returns False and reads fail instead of returning data. A sturdier client tests the connection and says so plainly instead of crashing:

PYTHON — guard the connection
if not client.connect():
    print("Could not connect — is the server up?")
    raise SystemExit

This is Chapter 3's silence outcome, caught in code: no connection means no conversation, and the friendly message points at the cause instead of leaving you a cryptic traceback. Use it in place of the bare client.connect() line. As programs grow, this kind of check is the difference between a script that works on a good day and one that tells you plainly what's wrong on a bad one.

You've written a working Modbus client and watched the protocol run end to end. Reading is only half the story. In Chapter 12 you turn the tables and build a server of your own, line by line, so you can simulate any device you need and give the writes in later chapters somewhere to land.

Check your understanding

1. After rr = client.read_holding_registers(...), rr.isError() returns True. What happened?

isError() is true when the server sent an exception. Silence shows up differently, as a failed connection or a timeout.

2. Input register 0 returns 235 and the register map says "temperature × 10." What does the client print?

The value is stored ten times larger, so the client divides by ten. Modbus moves the integer; the meaning comes from the map.

3. You move from the practice server to a real meter at 192.168.1.50. What usually changes in the client?

Localhost practice and field work use identical code. Only the connection details, and the unit address keyword, change.

4. Using the pinned pymodbus 3.7.4, how do you read from unit address 1?

3.7.4 uses slave=. The device_id= spelling arrived in 3.10.