DEV Community

Cover image for A Green 'Connected' Proves Little: Reading Modbus TCP Failures from Raw Bytes
Vladimir Chemeris
Vladimir Chemeris

Posted on AI-assisted

A Green 'Connected' Proves Little: Reading Modbus TCP Failures from Raw Bytes

I build Modbus TCP FieldKit, an iPhone and iPad app for Modbus TCP. Everything below uses Python on a laptop, with no app involved.

On my Mac, this probe connects to a Modbus device that doesn't exist:

$ python probe.py 192.0.2.1 502
MBAP: connected, then silence
RTU:  connected, then silence
Enter fullscreen mode Exit fullscreen mode

RFC 5737 reserves 192.0.2.0/24 for documentation, so no device should answer there. My Mac routes that address into a tunnel interface, utun4, and something on the tunnel completes the TCP handshake. A client showing a green "connected" at this point has told you nothing about a device.

You can keep the layers of a Modbus failure apart with about 30 lines of Python and no Modbus library.

The probe

# probe.py
import socket, struct, sys

def mbap_read(address, count, unit=1):
    pdu = struct.pack(">BHH", 3, address, count)
    return struct.pack(">HHHB", 1, 0, len(pdu) + 1, unit) + pdu

def rtu_read(address, count, unit=1):
    body = struct.pack(">BBHH", unit, 3, address, count)
    crc = 0xFFFF
    for byte in body:
        crc ^= byte
        for _ in range(8):
            crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
    return body + struct.pack("<H", crc)

def probe(host, port, frame, timeout=2.0):
    try:
        sock = socket.create_connection((host, port), timeout=timeout)
    except OSError as exc:
        return f"connect failed: {type(exc).__name__}: {exc}"
    with sock:
        sock.settimeout(timeout)
        sock.sendall(frame)
        try:
            reply = sock.recv(260)
        except TimeoutError:
            return "connected, then silence"
        return reply.hex(" ").upper() if reply else "connected, then closed by peer"

if __name__ == "__main__":
    host, port = sys.argv[1], int(sys.argv[2])
    print("MBAP:", probe(host, port, mbap_read(0, 1)))
    print("RTU: ", probe(host, port, rtu_read(0, 1)))
Enter fullscreen mode Exit fullscreen mode

It sends one read of holding register 0 in two framings: MBAP, the Modbus TCP header, and RTU over TCP, the form a transparent RS-485 converter expects.

Two framings on one laptop

I ran two pymodbus 3.16.1 servers on localhost on 30 October 2026, with Python 3.15 on macOS. Port 15020 speaks Modbus TCP, and port 15021 expects RTU frames the way a transparent converter does.

# servers.py
import asyncio
from pymodbus import FramerType
from pymodbus.server import ModbusTcpServer
from pymodbus.simulator import DataType, SimData, SimDevice

def device():
    return SimDevice(1, simdata=[SimData(0, values=[100, 250, 1024], datatype=DataType.REGISTERS)])

async def main():
    tcp = ModbusTcpServer(context=[device()], address=("127.0.0.1", 15020))
    rtu = ModbusTcpServer(context=[device()], address=("127.0.0.1", 15021), framer=FramerType.RTU)
    await asyncio.gather(tcp.serve_forever(), rtu.serve_forever())

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode
$ python probe.py 127.0.0.1 15029
MBAP: connect failed: ConnectionRefusedError: [Errno 61] Connection refused
RTU:  connect failed: ConnectionRefusedError: [Errno 61] Connection refused

$ python probe.py 127.0.0.1 15020
MBAP: 00 01 00 00 00 05 01 03 02 00 64
RTU:  connected, then silence

$ python probe.py 127.0.0.1 15021
MBAP: connected, then silence
RTU:  01 03 02 00 64 B9 AF
Enter fullscreen mode Exit fullscreen mode

Both good replies carry the same value, 00 64, which is 100. The first starts with the MBAP header and the second ends with a CRC. Paste either into this frame decoder to see the fields.

The wrong framing gets you silence from both servers, over a healthy TCP connection. A port number won't settle it for you: 8899 may belong to a transparent converter or to a Modbus TCP gateway. I compared the two framings in more detail on my site.

Exception replies prove the most

# exceptions.py
from probe import mbap_read, probe

print("addr 40000:", probe("127.0.0.1", 15020, mbap_read(40000, 1)))
print("unit 9:    ", probe("127.0.0.1", 15020, mbap_read(0, 1, unit=9)))
Enter fullscreen mode Exit fullscreen mode
addr 40000: 00 01 00 00 00 03 01 83 02
unit 9:     00 01 00 00 00 03 09 83 04
Enter fullscreen mode Exit fullscreen mode

Function code 83 is 03 with the high bit set, and the next byte names the exception. A reply like this means a Modbus stack read your frame, so you can stop checking cables. 02 is Illegal Data Address, and this exception code reference lists the others.

On the second line, pymodbus 3.16.1 answered 04, Server Device Failure, for a unit it doesn't serve. The specification gives gateways 0B for a target device that fails to respond, and some devices stay silent. A wrong Unit ID can look like any of the three.

Results and the proof each one gives

Result Proves Check next
ConnectionRefusedError A host answered and rejected the port Port number, service enabled
Connect timeout Nothing yet Subnet, VLAN, firewall, power
Connected, silent in both framings Something accepted TCP Unit ID, client limit, tunnels, wrong host
Connected, one framing answers You found the framing Set your client to match
83 02 A Modbus stack parsed your frame Register address
83 0A or 83 0B You reached a gateway The serial side behind it

I couldn't reproduce the connect timeout on this Mac, because the tunnel accepts any address. Turn your VPN or tunnel off on a field laptop before you trust a connect result.

The same failures through a library

# lib_view.py
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ModbusException

def attempt(label, host, port, address=0, device_id=1):
    client = ModbusTcpClient(host, port=port, timeout=1, retries=0)
    if not client.connect():
        print(f"{label}: connect() returned False")
        return
    try:
        r = client.read_holding_registers(address, count=1, device_id=device_id)
    except ModbusException as exc:
        print(f"{label}: raised {type(exc).__name__}")
    else:
        print(f"{label}: isError()={r.isError()}, registers={r.registers}")
    finally:
        client.close()

attempt("closed port", "127.0.0.1", 15029)
attempt("good read", "127.0.0.1", 15020)
attempt("illegal address", "127.0.0.1", 15020, address=40000)
attempt("unknown unit", "127.0.0.1", 15020, device_id=9)
attempt("MBAP to RTU port", "127.0.0.1", 15021)
attempt("tunnel", "192.0.2.1", 502)
Enter fullscreen mode Exit fullscreen mode
closed port: connect() returned False
good read: isError()=False, registers=[100]
illegal address: isError()=True, registers=[]
unknown unit: isError()=True, registers=[]
MBAP to RTU port: raised ModbusIOException
tunnel: raised ModbusIOException
Enter fullscreen mode Exit fullscreen mode

An exception comes back as a normal return value with an empty registers, so r.registers[0] raises IndexError unless you call isError() first. Wrong framing and the tunnel raise the same ModbusIOException, and your logs can't tell them apart. Run the probe in both framings before you start on the network.

My checklist for timeouts and refused connections continues from here with polling rate and second masters.

FieldKit runs the framing check on a phone: after a read times out, it offers one read with the other framing. The RTU over TCP page shows the converter setup.

Tell me in the comments which of these results cost you the most time on site.

Top comments (2)

Collapse
 
onelifevibe profile image
OneLifeVibe •

The tunnel example is the strongest part: a completed TCP handshake to 192.0.2.1, an address that should answer nothing, shows clearly why a green connected indicator proves almost nothing about the Modbus layer. The exception-reply section is equally useful — an 83 02 response at least proves a Modbus stack parsed the frame, which is far more information than silence. The results table would work well as a field checklist, with one addition: recording the Unit ID and framing used alongside each result, since a wrong Unit ID can look identical to a silent device.

Collapse
 
chamav profile image
Vladimir Chemeris •

Good call. The probe labels the framing and hides the unit, so I added a loop:

for unit in (1, 0, 9, 255):
    for name, frame in (("MBAP", mbap_read(0, 1, unit)), ("RTU ", rtu_read(0, 1, unit))):
        print(f"unit {unit:>3} {name}:", probe(host, port, frame))
Enter fullscreen mode Exit fullscreen mode

Same two servers, now on pymodbus 3.16.2. Units 0 and 255 print the same as 9:

port 15020, Modbus TCP
unit   1 MBAP: 00 01 00 00 00 05 01 03 02 00 64
unit   1 RTU : connected, then silence
unit   9 MBAP: 00 01 00 00 00 03 09 83 04
unit   9 RTU : connected, then silence

port 15021, RTU over TCP
unit   1 MBAP: connected, then silence
unit   1 RTU : 01 03 02 00 64 B9 AF
unit   9 MBAP: connected, then silence
unit   9 RTU : 09 83 04 C1 31
Enter fullscreen mode Exit fullscreen mode

pymodbus answers a unit it doesn't serve with 83 04. On this setup a wrong Unit ID gets a reply, and a wrong framing gets silence. A device that drops unknown units turns the unit 9 lines into silence in both framings, and then you need the logged unit to tell the two failures apart.

Behind a gateway the unit also picks the serial device. A good reply on unit 1 next to 83 0B on unit 9 points at the serial side, so the network is fine.