I build Modbus TCP FieldKit, an iPhone and iPad app for Modbus TCP. You won't need it here: the scripts below run on a laptop with Python and pymodbus.
Picture a meter manual with the line Voltage L1: 40021, FLOAT32 and no mention of word order. You drop the leading 4, pass 21 as the address, decode with your library's default and print:
volts: 230.92
hertz: 49.75
The meter's display reads 230.4 V and 49.98 Hz. Half a volt looks like calibration drift, so you move on to the next register.
That output carries two bugs. You would catch either one alone in seconds, because it gives you a number like -3.2e+26. Together they give you a reading close enough to pass a glance at the display.
Reproducing it
I wrote a small simulated meter with a layout you will meet on real devices: low word first. Install pymodbus and run this in one terminal:
# meter.py
import asyncio, struct
from pymodbus.server import ModbusTcpServer
from pymodbus.simulator import DataType, SimData, SimDevice
def cdab(value):
high, low = struct.unpack(">HH", struct.pack(">f", value))
return [low, high]
registers = [0] * 20 + cdab(230.4) + cdab(49.98) + [0] * 10
device = SimDevice(1, simdata=[SimData(0, values=registers, datatype=DataType.REGISTERS)])
async def main():
server = ModbusTcpServer(context=[device], address=("127.0.0.1", 15020))
await server.serve_forever()
asyncio.run(main())
Then read it the way the manual suggests:
# read_meter.py
from pymodbus.client import ModbusTcpClient
client = ModbusTcpClient("127.0.0.1", port=15020)
client.connect()
def read_float(address):
regs = client.read_holding_registers(address, count=2, device_id=1).registers
return client.convert_from_registers(regs, client.DATATYPE.FLOAT32)
print("volts:", round(read_float(21), 2))
print("hertz:", round(read_float(23), 2))
I ran both on 30 October 2026 with pymodbus 3.16.1 and Python 3.15 on macOS.
The two bugs
Off by one. 40021 names the 21st holding register, and the frame carries it as address 20. Section 4.4 of the Modbus specification numbers data model elements from 1 and PDU addresses from 0. I wrote up the prefixes and 6-digit references in this guide to register addressing.
Word order. The specification defines 16-bit registers and leaves multi-register values to the vendor. My meter stores the low word first, and convert_from_registers in pymodbus defaults to word_order="big", high word first. The four word orders go by the names ABCD, CDAB, BADC and DCBA.
Fixing both gives the right value:
# fixed.py
from pymodbus.client import ModbusTcpClient
client = ModbusTcpClient("127.0.0.1", port=15020)
client.connect()
regs = client.read_holding_registers(20, count=2, device_id=1).registers
volts = client.convert_from_registers(regs, client.DATATYPE.FLOAT32, word_order="little")
print("volts:", round(volts, 2))
volts: 230.4
The arithmetic behind the cancellation
address 20 21 22 23
register 6666 4366 EB85 4247
| volts | | Hz |
IEEE 754 encodes 230.4 as 0x43666666. The meter writes the low word 6666 first, then the high word 4366. A read from 21 grabs 4366 EB85. Decoding high word first puts the voltage's real high word in the right slot and fills the low half from the frequency.
The high word of a float32 holds the sign, the exponent and the top seven mantissa bits. At this magnitude 0x4366 pins the value between 230.0 and 231.0, and the low word picks a spot inside that volt. 0xEB85 lands at .92.
You can see how loud each bug is alone by decoding each window under all four orders:
# grid.py
import struct
from pymodbus.client import ModbusTcpClient
ORDERS = {"ABCD": (0, 1, 2, 3), "CDAB": (2, 3, 0, 1),
"BADC": (1, 0, 3, 2), "DCBA": (3, 2, 1, 0)}
def decode(pair, order):
raw = struct.pack(">HH", *pair)
return struct.unpack(">f", bytes(raw[i] for i in ORDERS[order]))[0]
client = ModbusTcpClient("127.0.0.1", port=15020)
client.connect()
start = 19
regs = client.read_holding_registers(start, count=6, device_id=1).registers
print("addr " + "".join(f"{o:>12}" for o in ORDERS))
for i in range(len(regs) - 1):
row = [f"{decode(regs[i:i + 2], o):12.5g}" for o in ORDERS]
print(f"{start + i:<6}" + "".join(row))
addr ABCD CDAB BADC DCBA
19 3.6734e-41 2.7154e+23 3.6734e-41 2.7154e+23
20 2.7185e+23 230.4 2.7201e+23 2.3069e+23
21 230.92 -3.2221e+26 2.3083e+23 -2.2137e-35
22 -3.222e+26 49.98 -2.2125e-35 49798
23 49.75 2.3776e-41 49664 2.5562e-41
Bug one alone sits at row 21 under CDAB: -3.2221e+26. Bug two alone sits at row 20 under ABCD: 2.7185e+23. Both together give 230.92 at row 21 under ABCD.
Rows 22 and 23 repeat the pattern for frequency with 49.98 and 49.75. You get two diagonals of sane-looking readings, so a range check can't choose between them.
The tell: decimals that follow the wrong quantity
I rewrote the simulated readings from a second client and read them back with the buggy code:
true V true Hz read V read Hz
230.40 49.98 230.92 49.75
231.70 49.98 231.92 49.75
229.10 49.98 229.92 49.75
230.40 50.01 230.04 50.00
230.40 49.90 230.60 49.75
The integer part of the voltage tracks the real voltage. The fraction stays at .92 while the voltage moves, then jumps to .04 and .60 after I change the frequency. The frequency reading snaps to steps of 0.25, because a zero register follows it in my map. On a real meter, the contents of the next register set that fraction.
Catching it on a device
- Read a window around the documented address and print the grid. Expect two plausible diagonals.
- Choose between them with a value whose decimals you can check: a display with one decimal place, or a setpoint you control. 230.92 and 230.4 differ in the first decimal.
- Change the quantity and read again. The fraction has to move with it.
- Store the address and word order next to the device model, and add a test with an exact expected value.
For a one-off check, paste two registers into this float converter, which decodes all four orders in the browser. The address converter does the 40021 arithmetic.
FieldKit stores format, word order, scale and unit with each register in a saved device profile, so the next meter of that model starts from the right settings. If you do this next to a panel with a phone, the client page shows what it reads and writes.
Have you hit a plausible but wrong reading on real hardware? Tell me which device in the comments.
Top comments (6)
This is the kind of failure an emulator can't catch if whoever writes it shares the client's assumption. Your simulated meter stores CDAB on purpose, so the bug shows up. Written from the same manual line, it would probably have encoded ABCD, both sides would agree, and every test would pass until the real meter.
I ran into a smaller version of this building a serial scale emulator this week: the original protocol never documented the terminator, so the emulator uses CR LF and the code marks it as an assumption, not a fact. Your "decimals that follow the wrong quantity" check is a nice way to turn an assumption like that into something verified on the device.
Do your saved device profiles distinguish a word order confirmed against a known value from one taken from the manual?
My emulator shows the bug because I knew the answer before I wrote it. Had I built it from that manual line, it would have agreed with the client. I'd have a green test suite and no idea what the meter does.
No, profiles don't track that. Word order is a plain setting, and the app doesn't record whether you checked it against a known value or copied it from the manual.
The notes field in the profile comes closest. You can write "CDAB, checked against display: L1 230.4 V, 2026-10-30" there, but the app doesn't validate that text. A default export also leaves notes out, so a colleague who imports your profile gets the word order without the evidence.
If I add a "confirmed" state, it should store the register and the reference value you compared it with. Otherwise the next person can't redo the check.
Your CR LF case sounds like the same problem without a display to check against. Do you have access to the original scale, or is the emulator the last reference you have?
No, I don't have the original scale anymore, so the emulator is the last reference. That's why the terminator stays marked as an assumption in the code instead of quietly becoming a fact.
If I ever get the device back, the first check is a raw capture of a single answer: the bytes after the weight settle the terminator, and any extra fields show up in the same dump.
Your point about the export dropping the notes is the one I'd keep: the evidence has to travel with the setting, or the next person inherits a confident value with no way to redo the check.
A raw capture of one answer is the right first check. You see the bytes before you decide what they mean.
I strip notes from the default export because free text can hold anything, IP addresses and customer site names included. You can still export them, but you have to opt in. A dedicated field could carry the register and the date without that risk. The reference value is harder: it's a reading from a real panel, and I keep readings out of the default export as well. I haven't settled that part.
That's a fair reason to keep notes out: free text is exactly where site names end up.
One middle ground for the reference value: export that the word order was confirmed, the register and the date, and keep the reading itself as a local-only field. Whoever imports the profile knows a check happened and which register to repeat it on, and they'd need a reading from their own panel to redo it anyway.
That corrects what I wrote earlier. I said the next person needs the reference value to redo the check, but their panel shows its own voltage, so my reading wouldn't help them. The register and the date are enough. I already export register addresses and a timestamp, so only the flag and the check date are new.
I'd add one state change. After import, the app should show "confirmed by the exporter", and switch to "confirmed here" once you repeat the check on your own device. Otherwise you can't tell a check on your meter from a check on someone else's.
Thanks for thinking this through with me.