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
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)))
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())
$ 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
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)))
addr 40000: 00 01 00 00 00 03 01 83 02
unit 9: 00 01 00 00 00 03 09 83 04
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)
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
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)
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.
Good call. The probe labels the framing and hides the unit, so I added a loop:
Same two servers, now on pymodbus 3.16.2. Units 0 and 255 print the same as 9:
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 theunit 9lines 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 0Bon unit 9 points at the serial side, so the network is fine.