Testing and interop
Test against something that is not live equipment first. OT Fabric gives you three ways to do that for Modbus, from a quick replay of a real device to a cross-implementation interop suite.
| Tool | What it simulates | Best for |
|---|---|---|
| modbus-simulator | A device described in a JSON fixture, with deterministic exceptions and a request trace | Integration tests and CI assertions for a gateway or client |
modbusctl server static / replay |
A real device, from an MCAP recording | Developing against realistic data without hardware |
| modbus-interop | Five independent Modbus TCP stacks in Docker | Proving a library interoperates |
modbus-simulator
Section titled “modbus-simulator”modbus-simulator is a deterministic, stateful Modbus TCP device simulator. You describe the device in a JSON fixture, it serves one or more devices on one listener (routed by unit ID 1-247), writes are kept in memory, and every request can be recorded to a JSON Lines trace that you assert on in CI.
Install
Section titled “Install”go install github.com/otfabric/modbus-simulator@latestBuilding from source needs Go 1.25 or later (make build writes bin/modbus-simulator).
Run a device
Section titled “Run a device”-
Write a fixture. Points are raw 16-bit values or go-modbus codec fields with typed values:
device.json {"$schema": "https://otfabric.dev/schemas/modbus-simulator/fixture-v1.json","schemaVersion": "modbus-simulator.fixture.v1","name": "gateway-integration-test","defaults": { "unknownAddress": "exception", "stateMode": "persistent" },"devices": [{"unitId": 1,"name": "inverter-a","identity": {"vendorName": "OTFabric","productCode": "GWTEST","majorMinorRevision": "1.0.0"},"state": {"coils": [ { "address": 0, "value": true, "writable": true } ],"holdingRegisters": [{ "address": 0, "value": 2301, "writable": false },{ "address": 1, "value": 501, "writable": true },{ "address": 10, "codec": "float32/layout:4321", "value": 230.5, "writable": true, "name": "voltage" }],"inputRegisters": [ { "address": 0, "value": 1234 } ]},"behavior": {"exceptions": [{"id": "hr-999-illegal","match": { "fc": 3, "area": "holdingRegisters", "address": 999 },"exceptionCode": "illegal_data_address"}]}}]} -
Validate it against the embedded schema and the semantic rules (exit 0, or 3 on failure):
Terminal window modbus-simulator validate device.json -
Serve it and record a trace. The default listen address is
:5020:Terminal window modbus-simulator run --fixture device.json --listen :1502 --trace trace.jsonl -
Point your client at
tcp://127.0.0.1:1502, unit 1. Stop the simulator with Ctrl-C when the test traffic is done.
Fixture behaviour worth knowing:
unknownAddress: exception(the default) answers any address not in the fixture with Illegal Data Address.zeroForReadsreturns 0 for unknown reads. Writes to unknown addresses always fail, so a mis-mapped gateway write cannot silently grow the map.stateMode: persistentkeeps written values for the life of the process.resetOnConnectionresets to the fixture on each new connection.- Exception rules match on
fc,area,addressoraddressRange, andquantity, and are evaluated before normal handling, so a test can force an exception on a valid address. - Codec IDs come from go-modbus.
modbus-simulator codecs listprints them; the repository ships fixtures that cover every codec.
Assert in CI
Section titled “Assert in CI”An expectation file lists assertions over the trace. Kinds are contains, notContains, count (with equals, atLeast or atMost), ordered and exact. Matching is by subset: only the fields you name are compared.
{ "schemaVersion": "modbus-simulator.expect.v1", "assertions": [ { "kind": "contains", "match": { "request": { "fc": 43 } } }, { "kind": "notContains", "match": { "request": { "address": 999 } } }, { "kind": "ordered", "sequence": [ { "request": { "fc": 6, "address": 1, "value": 777 }, "response": { "ok": true }, "stateChange": { "area": "holdingRegisters", "address": 1, "after": 777 } }, { "request": { "fc": 3, "address": 1, "quantity": 1 }, "response": { "ok": true, "values": [777] } } ] } ]}modbus-simulator assert-trace trace.jsonl --expect expected.jsonExit codes: 0 success, 2 usage, 3 validation, 4 transport or server, 8 assertion failure, 10 internal. Every flag has a MODBUS_SIMULATOR_* environment variable.
Limits
Section titled “Limits”| Supported in v1 | Not supported in v1 |
|---|---|
| FC01, FC02, FC03, FC04, FC05, FC06, FC15, FC16, FC43/14 | FC22, FC23 (deferred) |
| Modbus TCP | RTU or serial server (deferred) |
Replay a real device with modbusctl
Section titled “Replay a real device with modbusctl”When you have access to a device once, record it and keep the recording:
modbusctl client scan --ip 192.168.1.10 --algo smart --start 0 --end 5000 --output scan.mcapmodbusctl server static --input scan.mcap --port 1502server static serves the captured values unchanged. server replay steps through a client record capture over time (--loops 0, --interval). Both are Modbus TCP only, listen on all interfaces and accept any client: keep them on a trusted network. Captures can contain device identification and process values, so handle them as operational data.
modbus-interop
Section titled “modbus-interop”modbus-interop packages five independent Modbus TCP implementations as container images with the same command interface. It contains no library under test: a library pulls the images and owns its own scenarios and assertions.
| Adapter | Implementation | Version | Language |
|---|---|---|---|
libmodbus |
libmodbus | v3.2.0 | C |
pymodbus |
PyModbus | 3.15.0 | Python |
digitalpetri |
digitalpetri/modbus | 2.1.6 | Java |
nmodbus |
NModbus | 3.0.83 | C# |
tokiomodbus |
tokio-modbus | 0.17.0 | Rust |
Each image (ghcr.io/otfabric/modbus-interop-<adapter>) offers server, client <operation>, print-capabilities and print-fixture. Expected values come from the image’s own fixture, and a test skips (does not fail) a scenario that needs a feature the image declares false.
# Needs Docker and jqmake images # build the adapter imagesmake smoke # container contract of each imagemake interop # every reference client against every reference servermake run-libmodbus # a reference server on 127.0.0.1:1502The stacks differ. Not every one has FC22 or FC43, and they differ in what they check:
| libmodbus | PyModbus | digitalpetri | NModbus | tokio-modbus | |
|---|---|---|---|---|---|
| Server FC22 | yes | yes | yes | no | yes |
| Server FC43/14 | no | yes | no | no | yes |
| Client FC43/14 | no | yes | no | yes | yes |
| Server quantity check | yes | no | yes | yes | yes |
The reference servers are unauthenticated and accept writes from anyone. They publish on loopback only and must never be attached to a plant network.
What go-modbus has been validated against
Section titled “What go-modbus has been validated against”go-modbus runs its interop suite on every push (make interop in the go-modbus repository, about five minutes, Docker required). It is qualified against modbus-interop v0.1.0.
| Reference | go-modbus client to reference server | Reference client to go-modbus server |
|---|---|---|
| libmodbus v3.2.0 | pass without FC43 | pass without FC43 |
| PyModbus 3.15.0 | pass | pass |
| digitalpetri/modbus 2.1.6 | pass without FC43 | pass FC43 via raw PDU only |
| NModbus 3.0.83 | pass without FC43 and FC22 | pass without FC22 |
| tokio-modbus 0.17.0 | pass | pass |
The suite covers FC01 to FC06, FC15, FC16, FC22, FC23 and FC43/14. As a client, go-modbus reads every table at block boundaries and with the largest request of each function, reads writes back on a second connection, checks exceptions 1, 2 and 11, sends two thousand requests on one connection, and runs sixteen connections at once plus a pooled client. As a server, it answers 92 operations per reference client, including raw PDUs and exceptions 1, 2, 3 and 11, and each result must match what the client gets from a reference server.
To pin other builds, set MODBUS_INTEROP_<ADAPTER>_IMAGE; to run a subset, set MODBUS_INTEROP_ADAPTERS=libmodbus,pymodbus.
The suite also found a bug in go-modbus, fixed in v1.3.0: the server closed the connection on an FC15, FC16 or FC23 request whose byte count did not match its quantity, where the specification asks for exception 3. The findings about the reference stacks themselves are in COMPATIBILITY.md.
Serial tests without hardware
Section titled “Serial tests without hardware”- modbusctl’s tests use an in-memory Modbus device (
internal/mbtest) served over TCP, UDP, RTU and ASCII over TCP, and RTU and ASCII on a pseudo-terminal that stands in for a serial port (Linux and macOS). - go-serial’s
TestReadWritecreates a PTY loopback on Linux, macOS and BSD. For manual testing, create a pair withsocat -d -d pty,raw,echo=0 pty,raw,echo=0and setSERIAL_LOOPBACK_PTY1andSERIAL_LOOPBACK_PTY2.