User manual
Fieldbus appliance
One simulated device, served over eight industrial protocols from a single tag table. You describe the device in a JSON file and POST it to the board. Nothing is compiled, nothing is restarted, and every protocol changes at once.
1. Before you start
You need the board powered, on your network, and an address for it. It takes a
DHCP lease on eth0 at boot. If you have a serial console attached,
ip addr show eth0 on the board will tell you; otherwise look in your
router's lease table for a host answering on port 8080.
Everything below assumes a shell variable:
IP=192.168.1.50
Check it's alive:
curl -s http://$IP:8080/health
{"ok":true,"device":"pump-1","generation":1,"tags":8,"uptime_s":412}
That's the device the image ships with: a simulated pump, eight tags. These are the ports it serves:
- HTTP8080 — config and live values
- Modbus TCP502
- OPC UA4840 — anonymous
- MQTT1883
- BACnet/IP47808 — device 260001
- DNP320000 — outstation 10
- IEC 61850102 — MMS
- CANvcan0, on the board
No authentication, anywhere. The OPC UA server is anonymous, Modbus writes are unauthenticated, the MQTT broker is open and there is no TLS on any of it. That is deliberate — a password on one endpoint would imply a protection the device does not have. Put it on a lab network, not a plant network, and never on one reachable from the internet.
2. The device is a file
There is one file on the board, /etc/silt-sim.json, that describes
the whole device: what tags exist, how each one moves, and where it appears on
each protocol. Eight daemons read one shared tag table built from that file, which
is why no two protocols can disagree about a value.
You change the device by POSTing a new file. The four things you can do over HTTP:
| Request | Does |
|---|---|
GET /health | Device name, tag count, uptime, config generation. |
GET /tags | Every tag with its live value and its address on each protocol. |
GET /config | The config file currently running, verbatim. |
POST /config | Replace the device. Validated first; rejected whole or applied whole. |
A POST is checked before anything changes. If the file is wrong you get
400 and a sentence saying why, and the running device is untouched —
you cannot half-apply a config.
Two things worth knowing up front:
- A reboot restores the shipped pump. The POST writes
/etc/silt-sim.json, which lives on the read-write partition, so it does survive a reboot — but a reflash does not. Keep your configs on your own machine, in version control, not only on the board. - Values survive a reload. When you POST a new config, any tag that keeps the same name and type keeps its current value. A setpoint a client wrote is not reset just because you added a tag somewhere else in the file.
3. Your first device
The smallest config that will be accepted: a name and one tag that at least one protocol carries.
{
"device": "bench-1",
"tags": [
{
"name": "temperature",
"unit": "C",
"type": "float",
"sim": { "sine": { "min": 18, "max": 26, "period_s": 30 } },
"modbus": { "input": 0, "scale": 10 },
"opcua": "Temperature"
}
]
}
Save it as bench.json and push it:
curl -X POST --data-binary @bench.json http://$IP:8080/config
{"ok":true,"device":"bench-1","tags":1,"modbus":1,"opcua":1,"can":0}
The reply counts what it accepted: one tag, one Modbus mapping, one OPC UA node, no CAN. Read the live value back:
curl -s http://$IP:8080/tags
{"device":"bench-1","generation":2,"tags":[{"name":"temperature","value":23.8710,
"type":"float","unit":"C","writable":false,"signed":false,"sim":"sine",
"modbus":{"table":"input","address":0,"scale":10},"can":null,
"opcua":"ns=1;s=bench-1.Temperature"}]}
And over Modbus, where it reads as value × scale — 23.87
becomes 239:
mbpoll -m tcp -0 -1 -t 3 -r 0 -c 1 $IP
[0]: 239
That is the whole working cycle. Everything after this is detail.
4. Tag reference
A tag needs a name, a type, at least one protocol
mapping, and either a sim behaviour or writable. The
rest is optional.
| Field | Required | Meaning |
|---|---|---|
name | yes | Unique, under 32 characters. |
type | yes | float, int or bool. See below — this one matters more than it looks. |
unit | no | Under 16 characters. Omit it entirely if the tag is dimensionless; an empty string is refused. |
signed | no | true if the value can go negative. Without it a negative clamps to zero. |
sim | one of | How the value moves on its own. |
writable | these two | true if clients set it instead. Pair with initial. |
initial | no | Starting value for a writable tag. |
modbus | at least | Table and address. |
opcua | one of | Node name, unique, under 64 characters. |
can | these three | Frame id, byte offset, length. |
type — the field that decides five protocols
This is the one choice that is not cosmetic. Only Modbus lets you say what you
meant outright; the other protocols derive their whole object model from
type.
| type | BACnet | DNP3 | IEC 61850 |
|---|---|---|---|
float | Analog Input | Analog Input (g30) | MV |
int | Analog Input (BACnet has no integer input) | Counter (g20) | INS |
bool | Binary Input | Binary Input (g1) | SPS |
So a kWh total, a run-hours meter or a pulse count wants int: a
utility SCADA master expects a Counter for anything that accumulates, and an
Analog Input would read wrong to anyone who knows DNP3. A temperature, pressure or
current wants float. A contact wants bool.
A fault code or an enumeration has no good answer. int gives it a
correct 61850 INS and a misleading DNP3 Counter; float
gives it an Analog Input that is no truer. Pick whichever protocol your client
actually uses.
sim — how a value moves
Exactly one behaviour per tag. All of them take period_s, which may
be fractional.
| Behaviour | Moves | Takes |
|---|---|---|
sine | smoothly between bounds | min, max |
ramp | sawtooth between bounds | min, max |
walk | random walk inside bounds | min, max, step |
counter | +step each period, wraps at max | min, max, step |
toggle | 0/1 each period | — |
// a temperature that cycles over a minute
"sim": { "sine": { "min": 40, "max": 80, "period_s": 60 } }
// a pressure that jitters realistically
"sim": { "walk": { "min": 1.0, "max": 6.0, "step": 0.2, "period_s": 1 } }
// an hours counter ticking once a second
"sim": { "counter": { "min": 0, "max": 65000, "step": 1, "period_s": 1 } }
// an alarm that flips every two minutes
"sim": { "toggle": { "period_s": 120 } }
There is no plant model. A commanded value and a reported value are two independent tags and nothing couples them — the device will not ramp an output frequency toward a speed reference you wrote. If you need that behaviour, put it in whatever is polling the board.
modbus — table, address, scale
One of four tables, and an address in it. A register carries
value × scale, rounded.
| Key | Modbus table | Carries | mbpoll -t |
|---|---|---|---|
input | Input register (3x) | numbers, read-only | 3 |
holding | Holding register (4x) | numbers, writable | 4 |
discrete | Discrete input (1x) | bits, read-only | 1 |
coil | Coil (0x) | bits, writable | 0 |
"modbus": { "input": 2, "scale": 10 } // 54.3 reads as 543 at input register 2
"modbus": { "holding": 0, "scale": 100 } // a writable setpoint, two decimals
"modbus": { "coil": 0 } // a writable bit; scale is ignored
Addresses are zero-based and are what you wrote — the appliance does not use the
4xxxx convention. Two tags may not share an address in the same
table. A coil or discrete input can only carry a bool.
A register is 16 bits. max × scale must fit
in 65535, or 32767 if the tag is signed. A range of 0–5000 at
scale 100 is refused, because 500000 does not fit. There are no 32-bit register
pairs and no floats on the wire — see Limits.
opcua — the node name
A string, unique, under 64 characters. The tag appears as a variable under an
object named after the device, and its node id is
ns=1;s=<device>.<node>:
"device": "pump-1" + "opcua": "Temperature" → ns=1;s=pump-1.Temperature
"device": "meter-1" + "opcua": "Voltage.L1N" → ns=1;s=meter-1.Voltage.L1N
Dots in the node name are fine and are a reasonable way to group things; the server does not interpret them.
can — packing a frame
Tags that name a CAN id are packed into frames and sent on vcan0 on
the board, which exists whether or not the board has CAN hardware.
| Key | Meaning |
|---|---|
id | Frame id, decimal or "0x100". Up to 29 bits. |
byte | Offset into the 8-byte frame. |
len | 1, 2 or 4. byte + len must not exceed 8. |
scale | As Modbus: the wire carries value × scale. |
period_s | How often the frame goes out. |
order | "big" (default) or "little". |
Several tags can share one frame as long as they do not overlap:
// 0x100 byte 0-1: speed, byte 2: coolant temp
"can": { "id": "0x100", "byte": 0, "len": 2, "scale": 8, "period_s": 0.1 }
"can": { "id": "0x100", "byte": 2, "len": 1, "scale": 1, "period_s": 0.5 }
writable — a tag clients set
A tag is simulated or writable, never both. The validator refuses the combination, because two writers to one value is a race nobody finds until it matters.
{
"name": "setpoint",
"unit": "C",
"type": "float",
"writable": true,
"initial": 21,
"modbus": { "holding": 0, "scale": 10 },
"opcua": "Setpoint"
}
5. Reading it back
Using the four-tag thermostat from section 7, here is the same device from each protocol.
HTTP
curl -s http://$IP:8080/tags | jq -r '.tags[] | "\(.name)\t\(.value)"'
temperature 23.871
setpoint 21
heater_on 0
over_temp 1
Modbus TCP — port 502
// input register 0, the temperature, scale 10
mbpoll -m tcp -0 -1 -t 3 -r 0 -c 1 $IP
[0]: 239
// holding register 0, the setpoint
mbpoll -m tcp -0 -1 -t 4 -r 0 -c 1 $IP
[0]: 210
// discrete input 0, the over-temperature flag
mbpoll -m tcp -0 -1 -t 1 -r 0 -c 1 $IP
[0]: 1
An address no tag uses answers ILLEGAL DATA ADDRESS rather than zero. That is deliberate: a simulator that returns 0 for everything hides the client bug you were looking for.
OPC UA — port 4840, anonymous
Point any client at opc.tcp://$IP:4840 and browse Objects. With
Python and asyncua:
python3 - <<'PY'
import asyncio
from asyncua import Client
async def main():
async with Client(url="opc.tcp://192.168.1.50:4840") as c:
node = c.get_node("ns=1;s=bench-1.Temperature")
print(await node.read_value())
asyncio.run(main())
PY
23.871
MQTT — port 1883
Every tag is published retained as it changes, plus one JSON object with all of them. Retained means a dashboard that connects later gets the current value immediately instead of waiting for the next change.
mosquitto_sub -h $IP -t 'silt/bench-1/#' -v
silt/bench-1/temperature 23.871
silt/bench-1/setpoint 21
silt/bench-1/heater_on 0
silt/bench-1/state {"temperature":23.871,"setpoint":21,...}
BACnet/IP — port 47808, device 260001
Floats and ints become Analog Inputs, bools become Binary Inputs, numbered
in the order the tags appear in your file. With
bacnet-stack's tools:
bacwi -1 // who-is, find the device
bacrp 260001 analog-input 0 present-value // temperature
23.871002
bacrp 260001 binary-input 0 present-value // over_temp
active
Instance numbers come from tag order. Insert a tag in the middle of your file and everything after it renumbers. BACnet clients discover and cache instance numbers, so a cached client will then read the wrong object without complaining. Append tags rather than inserting them, or make the client re-discover.
DNP3 — port 20000
The outstation is link address 10 and expects a master at 1. Run an integrity poll from any DNP3 master. Floats arrive as Analog Inputs (group 30), ints as Counters (group 20), bools as Binary Inputs (group 1).
IEC 61850 — port 102, MMS
The model is built at runtime from your tag table, so it follows the config
rather than a fixed .cid file. Browse
siltIEDSILT/GGIO1 with IedExplorer or libiec61850's client example.
Floats are MV, ints INS, bools SPS.
CAN — vcan0, on the board
ssh root@$IP candump -t d vcan0
(0.000000) vcan0 100 [8] 09 60 50 00 00 00 00 00
(0.100000) vcan0 100 [8] 09 62 50 00 00 00 00 00
6. Writing values
A tag marked writable can be set three ways. All three write into
the same shared table, so a value written over one protocol is immediately visible
on all the others.
// Modbus: holding register 0, scale 10, so 225 means 22.5
mbpoll -m tcp -0 -t 4 -r 0 $IP 225
// MQTT: publish to the tag's /set topic
mosquitto_pub -h $IP -t 'silt/bench-1/setpoint/set' -m 22.5
// a coil, over Modbus
mbpoll -m tcp -0 -t 0 -r 0 $IP 1
And over OPC UA, by writing the node. Confirm from any other protocol:
curl -s http://$IP:8080/tags | jq -r '.tags[]|select(.name=="setpoint")|.value'
22.5
BACnet, DNP3 and 61850 are read-only. Writable tags appear there and can be read, but not set. Those protocols carry real control models — priority arrays, select-before-operate — and a half-implemented version would be worse than none. Use Modbus, OPC UA or MQTT to write.
7. Worked examples
A thermostat: readings, a setpoint and an alarm
Four tags covering all three types and both directions.
{
"device": "bench-1",
"tags": [
{ "name": "temperature", "unit": "C", "type": "float",
"sim": { "sine": { "min": 18, "max": 26, "period_s": 30 } },
"modbus": { "input": 0, "scale": 10 }, "opcua": "Temperature" },
{ "name": "setpoint", "unit": "C", "type": "float",
"writable": true, "initial": 21,
"modbus": { "holding": 0, "scale": 10 }, "opcua": "Setpoint" },
{ "name": "heater_on", "type": "bool",
"writable": true, "initial": 0,
"modbus": { "coil": 0 }, "opcua": "HeaterOn" },
{ "name": "over_temp", "type": "bool",
"sim": { "toggle": { "period_s": 300 } },
"modbus": { "discrete": 0 }, "opcua": "OverTemp" }
]
}
An energy total and a sub-zero reading
Two things that catch people out: a counter that must be int to
reach DNP3 as a Counter, and an outside-air temperature that must be
signed or it reads 0 °C on a −12 °C day.
{
"device": "meter-1",
"tags": [
{ "name": "kwh_import", "unit": "kWh", "type": "int",
"sim": { "counter": { "min": 0, "max": 65000, "step": 1, "period_s": 4 } },
"modbus": { "input": 20, "scale": 1 }, "opcua": "Energy.ActiveImport" },
{ "name": "ambient", "unit": "C", "type": "float", "signed": true,
"sim": { "sine": { "min": -15, "max": 35, "period_s": 90 } },
"modbus": { "input": 4, "scale": 10 }, "opcua": "AmbientTemperature" }
]
}
An engine on CAN
Two signals sharing one frame, plus a warning bit in another.
{
"device": "engine-1",
"tags": [
{ "name": "eng_speed", "unit": "rpm", "type": "float",
"sim": { "walk": { "min": 700, "max": 2400, "step": 20, "period_s": 1 } },
"can": { "id": "0x100", "byte": 0, "len": 2, "scale": 8, "period_s": 0.1 },
"modbus": { "input": 0, "scale": 1 }, "opcua": "Engine.Speed" },
{ "name": "coolant_temp", "unit": "C", "type": "float",
"sim": { "sine": { "min": 70, "max": 96, "period_s": 180 } },
"can": { "id": "0x100", "byte": 2, "len": 1, "scale": 1, "period_s": 0.5 },
"opcua": "Engine.CoolantTemp" },
{ "name": "oil_warning", "type": "bool",
"sim": { "toggle": { "period_s": 240 } },
"can": { "id": "0x101", "byte": 0, "len": 1, "scale": 1, "period_s": 1 },
"modbus": { "discrete": 0 }, "opcua": "Engine.OilWarning" }
]
}
Note coolant_temp has no Modbus mapping at all. A tag needs at least
one protocol, not all of them.
8. Starting from a CSV register map
For anything bigger than a few dozen tags, do not hand-write the JSON. The importer reads a vendor CSV or a tab-separated table pasted out of a manual, and writes the config for you.
silt-import-map -device meter-1 -o meter.json my-register-map.csv
It matches column headers loosely, so Address, Modbus
Address, Register and Reg all mean the same thing.
Override any guess with -col FIELD=HEADER.
Your register map says where a value lives. It does not say what the value
is, so the importer infers type from the unit and the name —
and prints every decision it made, so you can check them:
columns access="Access" addr="Register" scale="Scale" unit="Units"
row tag type modbus scale why
6 power_factor float input 3004 1000 default
! kept the map's scale 1000 and clipped our guessed range to 0..65.535,
which is what that scale can carry; give min/max to set it properly
7 total_active_energy_import int input 3005 1 unit 'kWh' accumulates
! declared 'UINT32' is wider than 16 bits; this will be wrong above 65535
Read the report before you POST the result. The importer is a clerk, not an engineer — only you know whether "unit 'h' plus 'hours' in the name" was right for your plant. The places it most often needs correcting:
- Ambiguous units.
h,sandkgaccumulate only when the name agrees. "Total Run Hours" is a counter; "Accel Time" is a parameter. - Unitless names. A point with no unit whose description contains status, alarm, run or command is read as a contact. A unitless number, like a fault code, comes through as a float.
- Missing ranges. With no min/max column the range comes from a coarse per-unit table. Adding min/max to the map is the single most useful column you can supply.
It never writes a config the board would refuse: it warns, clips a range it cannot carry, or drops a colliding Modbus mapping, but the output is always valid. It also runs entirely locally — no network, nothing uploaded — which matters when the map is a customer's.
silt-import-map and silt-simlint are in the tools
bundle supplied with your image.
silt-simlint checks a config on your own machine, using the same
validator the board runs, so you find a mistake in a second instead of as an HTTP
400 from a board you have to be standing next to:
silt-simlint -v meter.json
ok meter.json device=meter-1 16 tag(s)
tag type sim modbus opcua
v_l1_n float walk input 0 x10 Voltage.L1N
kwh_import int counter input 20 x1 Energy.ActiveImport
...
The -v table is what a client will actually find. A register map that
looked right in JSON and wrong in that table is the reason the flag exists.
9. When a config is refused
A rejected POST returns 400 with one sentence and changes nothing.
These are the real messages, with what to do about each.
| Message | Fix |
|---|---|
tag "t": 0..5000 scaled by 100 does not fit a 16-bit register |
Lower the scale, or narrow the range. max × scale must be under 65535. |
tag "t": -400..400 scaled by 100 does not fit a signed 16-bit register |
Same, but the ceiling is 32767 because the tag is signed. |
tag "t": writable and simulated at once, so two writers would fight over it; drop one |
Remove either sim or writable. A reported value and a commanded value are two tags. |
tags "a" and "b" share Modbus address 5 |
Two tags in one table at one address. Move one. |
tags "u" and "v" share OPC UA node "Temperature" |
Node names must be unique across the device. |
two tags are named "t" |
Tag names must be unique too. |
tag "t": coil carries one bit, so the tag must be bool |
Use input or holding for numbers. |
tag "t": can byte 7 plus length 4 runs past the 8-byte frame |
Lower byte or len; they must sum to 8 or less. |
tag "t": no protocol carries it, so nothing could read it |
Give it a modbus, opcua or can mapping. |
tag "t": unit must be a short string |
Usually "unit": "". Omit the key entirely for a dimensionless tag. |
tag "t": sim names no known behaviour (sine, ramp, toggle, counter, walk) |
Check the spelling of the behaviour. |
the tags array is empty |
A device needs at least one tag. |
not valid JSON, near: } |
A trailing comma or an unclosed brace. The fragment shown is where the parser stopped. |
10. Limits
- 2560 tags. Enough for a substation bay or a plant with forty meters.
- 16-bit Modbus registers only. No 32-bit register pairs and no IEEE754 floats on the wire. A totalizer wraps at 65535. If what you are testing is 32-bit register handling, this is not the right tool.
- Scale, not offset. The wire carries
value × scaleand nothing is added. A J1939 temperature with its −40 bias cannot be expressed faithfully. - Name under 32 characters, unit under 16, OPC UA node under 64.
- Config file under 1 MB.
- No authentication or TLS on any protocol.
- No plant model. Tags move as their
simsays and are not related to one another.
11. Troubleshooting
The board does not answer on 8080
Check it has an address and that you can reach it at all:
ping -c2 $IP
ssh root@$IP 'ip addr show eth0; /etc/init.d/S88silt-sim status'
If /health is silent but ssh works, the sim core did not start —
most often a hand-edited /etc/silt-sim.json that is not valid. The
daemon logs the reason:
ssh root@$IP 'dmesg | tail -20; logread 2>/dev/null | grep silt | tail -20'
A protocol's port is closed
See which daemons are actually listening:
ssh root@$IP 'netstat -lntup | grep silt'
Each protocol is its own daemon. One failing to start leaves the other seven working, which is why a single missing port is the usual symptom rather than a dead board.
A tag is missing from one protocol only
Almost always the mapping: a tag with no opcua key does not exist on
OPC UA, and one with no can key is not on the bus. Check what the
board thinks:
curl -s http://$IP:8080/tags | jq '.tags[] | {name, modbus, opcua, can}'
A BACnet client reads the wrong value
You almost certainly inserted a tag rather than appending one, and the client has cached the old instance numbers. Re-discover, or append in future.
Values look stuck
Check generation in /health — it increments on every
accepted POST. If it did not change, your config was not applied. A writable tag
also does not move on its own; only a tag with a sim does.
12. Support
If something here does not match what your board does, that is a bug in one of the two and I would like to know. The most useful thing to send is the config you POSTed and the output of:
curl -s http://$IP:8080/health
curl -s http://$IP:8080/tags
ssh root@$IP 'netstat -lntup | grep silt'
For an importer problem, send the report it printed rather than the register map — the report names the rules that fired and contains none of your register values.
Custom images, additional protocols, 32-bit register support and board bring-up are available as consulting work. Get in touch.