Deterministic, stateful Modbus TCP device simulator for integration and end-to-end testing. Load a declarative JSON fixture (raw registers or go-modbus codec fields with typed values), serve one or more Modbus devices from a single listener (plain TCP or TLS), optionally record a JSONL trace of every request, and assert traces in CI.
Repository: github.com/otfabric/modbus-simulator
See REQUIREMENTS.md for the full specification.
- 📦 Install
- 🚀 Quick start
- ⌨️ Commands
- 🌐 Global flags
- ✅
validate ▶️ run- 🔢
codecs list - 🔍
assert-trace - ℹ️
version - 🐚
completion - 🚦 Exit codes
- 📄 Artifacts
- 🧩 Codec register fields
- 📡 Supported function codes (v1)
- 🧠 State, reset and reload
- 💥 Fault injection
- 📣 Broadcast
- 🔒 TLS
⚠️ Limitations- 📚 Examples
- 🛠️ Development
- 📜 License
Download a pre-built binary for Linux, macOS or Windows from GitHub Releases, or install with Go (1.25+):
go install github.com/otfabric/modbus-simulator@latestOr build from a checkout:
make build # lint + test, then bin/modbus-simulator
make install # copies to /usr/local/bin (uses sudo)make build runs the full make check first and needs staticcheck and golangci-lint v2 on PATH. For a plain build without them: go build -o bin/modbus-simulator .
Validate a fixture and start the simulator:
FIXTURE=examples/all-function-codes.json
modbus-simulator validate "$FIXTURE"
modbus-simulator run --fixture "$FIXTURE" --listen :1502While the simulator runs, point your Modbus client or gateway at tcp://127.0.0.1:1502 (unit ID 1).
For trace recording and CI assertions, add --trace trace.jsonl, generate traffic, stop the server (Ctrl+C), then run assert-trace with an expectation file — see assert-trace and Artifacts.
Per–function-code and codec fixtures are in Examples. Command details and flags are in Commands below.
modbus-simulator [--debug]
├── validate <fixture.json>
├── run
├── assert-trace <trace.jsonl>
├── codecs list
├── version
└── completion [bash|zsh|fish|powershell]
| Flag | Default | Description |
|---|---|---|
--debug |
false |
Print debug information (applies to all commands) |
Validate a fixture against the embedded JSON Schema and semantic rules.
| Flag | Environment variable | Default | Description |
|---|---|---|---|
--format |
MODBUS_SIMULATOR_FORMAT |
text |
Output format: text or json |
# Exit 0 on success, 3 on validation failure
modbus-simulator validate device.json
# Machine-readable summary
modbus-simulator validate device.json --format jsonStart the stateful Modbus TCP simulator from a fixture. Runs until interrupted (SIGINT/SIGTERM).
| Flag | Environment variable | Default | Description |
|---|---|---|---|
--fixture |
MODBUS_SIMULATOR_FIXTURE |
(required) | Path to fixture JSON |
--listen |
MODBUS_SIMULATOR_LISTEN |
:5020 |
TCP listen address (host:port) |
--trace |
MODBUS_SIMULATOR_TRACE |
(none) | JSONL trace output path |
--trace-decoded |
MODBUS_SIMULATOR_TRACE_DECODED |
false |
Include typed codec decode overlay in trace records |
--trace-raw |
MODBUS_SIMULATOR_TRACE_RAW |
false |
Include the request and response PDU bytes (hex) in trace records |
--max-clients |
MODBUS_SIMULATOR_MAX_CLIENTS |
0 (unlimited) |
Max concurrent clients; a client over the limit is disconnected |
--control |
MODBUS_SIMULATOR_CONTROL |
(disabled) | HTTP control API address (host:port) — see State, reset and reload |
--tls-cert |
MODBUS_SIMULATOR_TLS_CERT |
(none) | Server certificate (PEM) — see TLS |
--tls-key |
MODBUS_SIMULATOR_TLS_KEY |
(none) | Server private key (PEM) |
--tls-client-ca |
MODBUS_SIMULATOR_TLS_CLIENT_CA |
(none) | CA certificates (PEM) that client certificates must chain to |
# Serve on the default port (:5020)
modbus-simulator run --fixture device.json
# Custom listen address and trace output
modbus-simulator run --fixture device.json --listen :1502 --trace trace.jsonl
# Trace with typed codec decode overlay and the PDU bytes
modbus-simulator run --fixture examples/codec-floats.json --trace trace.jsonl --trace-decoded --trace-raw
# Control API for reset and reload
modbus-simulator run --fixture device.json --control 127.0.0.1:5021
# TLS with client certificates
modbus-simulator run --fixture device.json --tls-cert server.pem --tls-key server.key --tls-client-ca ca.pem
# Environment variables instead of flags
MODBUS_SIMULATOR_FIXTURE=device.json MODBUS_SIMULATOR_LISTEN=:5020 modbus-simulator runList go-modbus codec stable IDs and metadata. Use these IDs in fixture codec fields.
| Flag | Environment variable | Default | Description |
|---|---|---|---|
--family |
(none) | (none) | Filter by family: integer, float, text, bcd, bytes, network, hardware_address, decimal_limb, time, … |
--format |
MODBUS_SIMULATOR_FORMAT |
text |
Output format: text or json |
modbus-simulator codecs list
modbus-simulator codecs list --family float --format jsonAssert a JSONL trace against an expectation file (modbus-simulator.expect.v1).
| Flag | Environment variable | Default | Description |
|---|---|---|---|
--expect |
MODBUS_SIMULATOR_EXPECT |
(required) | Path to expectation JSON |
--format |
MODBUS_SIMULATOR_FORMAT |
text |
Output format: text or json |
# Exit 8 if any assertion fails
modbus-simulator assert-trace trace.jsonl --expect expected.json
# Machine-readable report
modbus-simulator assert-trace trace.jsonl --expect expected.json --format jsonPrint build metadata (version, tag, commit, build date).
None.
modbus-simulator versionGenerate shell completion scripts for bash, zsh, fish, or powershell.
None (shell name is a required positional argument).
# Bash
source <(modbus-simulator completion bash)
# Zsh
source <(modbus-simulator completion zsh)
# Fish
modbus-simulator completion fish | source
# PowerShell
modbus-simulator completion powershell | Out-String | Invoke-Expression| Code | Meaning |
|---|---|
0 |
Success |
2 |
Usage / flag error |
3 |
Validation error (invalid fixture, trace, or expectation) |
4 |
Transport / server error |
8 |
Assertion failure |
10 |
Internal error |
Three JSON artifacts drive the workflow:
| Artifact | schemaVersion |
Schema file | Published $id |
|---|---|---|---|
| Fixture | modbus-simulator.fixture.v1 |
schemas/fixture-v1.json | https://otfabric.dev/schemas/modbus-simulator/fixture-v1.json |
| Trace (JSONL) | modbus-simulator.trace.v1 |
schemas/trace-v1.json | https://otfabric.dev/schemas/modbus-simulator/trace-v1.json |
| Expectation | modbus-simulator.expect.v1 |
schemas/expect-v1.json | https://otfabric.dev/schemas/modbus-simulator/expect-v1.json |
schemas/
├── fixture-v1.json # device state, identity, behavior (raw + codec register points)
├── trace-v1.json # one JSON object per served request (JSONL)
└── expect-v1.json # trace assertions (contains, count, ordered, exact, …)
- Fixture — declarative initial Modbus world; raw uint16/bool points or codec-backed
multi-register fields with typed values. Semantic validation uses the go-modbus codec
registry (see
validateandcodecs list). - Trace — append-only execution log; optional output from
run --trace. - Expectation — subset matchers evaluated by
assert-trace.
Schemas are embedded in the binary so validate and assert-trace work offline. Set "$schema" in your JSON to the published $id or a relative path (e.g. "schemas/fixture-v1.json") for editor validation; the CLI selects the schema from schemaVersion, not from $schema.
Register points in a fixture can use raw unsigned integers (0–65535, one register each) or a go-modbus codec with a typed JSON value. The simulator encodes codec values to wire registers at load time; FC handling stays register-native on the wire.
Raw point:
{ "address": 100, "value": 2301, "writable": false, "name": "raw_counter" }Codec point:
{
"address": 10,
"codec": "float32/layout:4321",
"value": 230.5,
"writable": true,
"name": "voltage"
}| Field | Meaning |
|---|---|
codec |
Stable ID from go-modbus/codec (e.g. float32/layout:4321, ascii/registers:4, ip_addr, datetime2_s2000) |
value |
Typed JSON matching the codec (numbers, strings, byte arrays, RFC3339 times, IP/MAC strings) |
| span | Derived from the codec register count — do not specify count; overlapping spans in one area are rejected |
Discover IDs with modbus-simulator codecs list. Validation runs JSON Schema plus semantic encode/decode round-trip checks. Optional trace overlay: run --trace-decoded adds a decoded[] field when a read/write fully covers a codec field.
| FC | Area |
|---|---|
| 1, 5, 15 | Coils |
| 2 | Discrete inputs (read-only) |
| 3, 6, 16, 22, 23 | Holding registers |
| 4 | Input registers (read-only) |
| 43 / 14 | Device identification |
- FC22 (mask write) sets the register to
(current AND andMask) OR (orMask AND NOT andMask). - FC23 (read/write multiple) makes the write first, then the read, as one step. If either is not allowed, nothing is written.
All devices in a fixture share one listener and are routed by unit ID (1–247). A request for a unit ID that is not in the fixture is answered with an illegal_function exception.
A device's stateMode (set in defaults or per device in behavior) decides who sees a write:
stateMode |
State |
|---|---|
persistent (default) |
One state shared by all clients, for as long as the simulator runs. |
resetOnConnection |
Every client connection works on its own copy of the fixture values, discarded when it disconnects. Clients do not see each other's writes, and a new connection never disturbs one that is in progress. |
With run --control <host:port> a running simulator also takes commands over HTTP, so a test suite can start it once and reset it between cases:
| Request | Effect |
|---|---|
POST /reset |
Restore every unit to the fixture values, for all clients |
POST /reset?unit=N |
Restore one unit (404 when it is not in the fixture) |
POST /reload |
Read the fixture file again and serve it from then on. A fixture that is not valid is refused with 422 and the simulator carries on unchanged |
GET /healthz |
200 while the simulator runs |
modbus-simulator run --fixture device.json --control 127.0.0.1:5021 &
curl -X POST http://127.0.0.1:5021/reset
curl -X POST http://127.0.0.1:5021/reloadReplies are JSON ({"ok":true}, or {"ok":false,"error":"…"}). The control API has no authentication: bind it to a loopback address. Resets and reloads are not written to the trace; seq simply continues.
Fault rules in a device's behavior.faults change how a matching request is answered (see REQUIREMENTS.md for the match grammar):
fault |
On the wire | In the trace |
|---|---|---|
delay |
The normal response, after delayMs |
ok: true, matchedRule |
timeout |
No response; the connection stays open, so the client runs into its own timeout. delayMs, when set, is waited first |
ok: false, fault: "timeout" |
connectionClose |
The connection is closed without a response | ok: false, fault: "connectionClose" |
{ "id": "hr-100-timeout", "match": { "fc": 3, "address": 100 }, "fault": "timeout" }Set "broadcast": true at the top level of a fixture to make unit 0 the broadcast address, as on a serial line behind a gateway:
- A write to unit 0 (FC 5, 6, 15, 16, 22) is executed by every device that can take it; a device where the address is unknown or read-only ignores it. No response is sent.
- Any other request to unit 0 is ignored and left unanswered.
- The trace has one record with
"unitId": 0and"response": {"ok": true, "noResponse": true}; each entry ofstateChangesnames theunitIdit changed. - Exception and fault rules belong to a device and are not applied to a broadcast.
Without it (the default), unit 0 is an unknown unit and is answered with illegal_function.
Give run a server certificate, its key and a client CA to serve Modbus/TCP Security instead of plain TCP:
modbus-simulator run --fixture device.json --listen :8020 \
--tls-cert server.pem --tls-key server.key --tls-client-ca ca.pemAll three are required: clients must present a certificate that chains to --tls-client-ca (mutual TLS). The trace records "transport": "tcp+tls".
- No RTU/serial server. The listener is TCP, plain or with TLS.
- Codec fields are register areas only (no coil/bit codecs). Writes need not align with codec field boundaries;
decoded[](--trace-decoded) lists only fields fully covered by the request. --trace-rawPDUs are re-encoded, not captured: the request as the simulator decoded it and the response it returned, encoded as the specification prescribes. Padding bits of an FC15 request are zero, and the response of FC43 is not included.
See RELEASE.md for per-release notes.
Ready-to-run fixtures in examples/ — 21 total (12 function-code, 9 codec bundles). Each file has a TCP integration test in TestExamplesIntegration (make test). Codec bundles are checked against modbus-simulator codecs list by TestExamples_AllCodecsHaveExample (195 IDs).
Function codes
| Fixture | FC | Try it |
|---|---|---|
| fc01-read-coils.json | 1 — Read coils | modbus-simulator run --fixture examples/fc01-read-coils.json |
| fc02-read-discrete-inputs.json | 2 — Read discrete inputs | modbus-simulator run --fixture examples/fc02-read-discrete-inputs.json |
| fc03-read-holding-registers.json | 3 — Read holding registers | modbus-simulator run --fixture examples/fc03-read-holding-registers.json |
| fc04-read-input-registers.json | 4 — Read input registers | modbus-simulator run --fixture examples/fc04-read-input-registers.json |
| fc05-write-single-coil.json | 5 — Write single coil | modbus-simulator run --fixture examples/fc05-write-single-coil.json |
| fc06-write-single-register.json | 6 — Write single register | modbus-simulator run --fixture examples/fc06-write-single-register.json |
| fc15-write-multiple-coils.json | 15 — Write multiple coils | modbus-simulator run --fixture examples/fc15-write-multiple-coils.json |
| fc16-write-multiple-registers.json | 16 — Write multiple registers | modbus-simulator run --fixture examples/fc16-write-multiple-registers.json |
| fc22-mask-write-register.json | 22 — Mask write register | modbus-simulator run --fixture examples/fc22-mask-write-register.json |
| fc23-read-write-multiple-registers.json | 23 — Read/write multiple registers | modbus-simulator run --fixture examples/fc23-read-write-multiple-registers.json |
| fc43-device-identification.json | 43/14 — Device identification | modbus-simulator run --fixture examples/fc43-device-identification.json |
| all-function-codes.json | 1–6, 15, 16 and 43 in one fixture | modbus-simulator run --fixture examples/all-function-codes.json |
Codecs — nine bundled fixtures cover every codec from modbus-simulator codecs list (195 IDs). Each file lists one example per family member with non-overlapping addresses.
| Fixture | Family | Codecs | Try it |
|---|---|---|---|
| codec-integers.json | integer | 29 | modbus-simulator validate examples/codec-integers.json |
| codec-floats.json | float | 8 | modbus-simulator validate examples/codec-floats.json |
| codec-text.json | text | 60 | modbus-simulator validate examples/codec-text.json |
| codec-bcd.json | bcd | 48 | modbus-simulator validate examples/codec-bcd.json |
| codec-bytes.json | bytes | 26 | modbus-simulator validate examples/codec-bytes.json |
| codec-decimal-limb.json | decimal_limb | 12 | modbus-simulator validate examples/codec-decimal-limb.json |
| codec-datetime.json | time | 8 | modbus-simulator validate examples/codec-datetime.json |
| codec-network.json | network | 2 | modbus-simulator validate examples/codec-network.json |
| codec-hardware.json | hardware_address | 2 | modbus-simulator validate examples/codec-hardware.json |
Regenerate bundled examples after adding codecs: make generate-codec-examples
Each fixture uses unit ID 1 on the default listen address :5020. The description field in every file summarizes the registers or coils to exercise.
make help # list targets
make test # unit + race tests (includes example fixtures)
make check # fmt, lint, vet, test, coverage
make soak # loop the chaos and stress tests (SOAKTIME=2m)
make fuzz # run the fuzz targets (FUZZTIME=30s each)
make build # build bin/modbus-simulator
make build-all # cross-compile release binaries
make generate-codec-examples # regenerate bundled codec fixturesShell completion setup is documented under completion.
make test includes a short run of all of them:
- Stress (
stress_test.go) — parallel clients check every answer against a model of their own registers while they all contend for a shared block; connection churn, the client limit and shutdown with requests in flight. - Chaos (
chaos_test.go) — misbehaving raw TCP clients (garbage, truncated and oversized frames, random PDUs, illegal quantities, slow loris, pipelining, half-closed and abandoned connections) run next to the well-behaved ones, which must not notice. - Fuzz (
fuzz_test.go) — fixtures, expectation files and arbitrary request sequences.
After each run the trace must be schema-valid, numbered 1, 2, 3, … in file order, and replay as a sequential history: every read returns what the earlier writes left behind. Randomized tests log their seed; replay one with MODBUS_SIMULATOR_TEST_SEED=<seed>.
MIT License. Copyright (c) 2026 OTFabric. See LICENSE.