Vinod Halaharvi Support

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:

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:

RequestDoes
GET /healthDevice name, tag count, uptime, config generation.
GET /tagsEvery tag with its live value and its address on each protocol.
GET /configThe config file currently running, verbatim.
POST /configReplace 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:

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.

FieldRequiredMeaning
nameyesUnique, under 32 characters.
typeyesfloat, int or bool. See below — this one matters more than it looks.
unitnoUnder 16 characters. Omit it entirely if the tag is dimensionless; an empty string is refused.
signednotrue if the value can go negative. Without it a negative clamps to zero.
simone ofHow the value moves on its own.
writablethese twotrue if clients set it instead. Pair with initial.
initialnoStarting value for a writable tag.
modbusat leastTable and address.
opcuaone ofNode name, unique, under 64 characters.
canthese threeFrame 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.

typeBACnetDNP3IEC 61850
floatAnalog InputAnalog Input (g30)MV
intAnalog Input (BACnet has no integer input)Counter (g20)INS
boolBinary InputBinary 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.

BehaviourMovesTakes
sinesmoothly between boundsmin, max
rampsawtooth between boundsmin, max
walkrandom walk inside boundsmin, max, step
counter+step each period, wraps at maxmin, max, step
toggle0/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.

KeyModbus tableCarriesmbpoll -t
inputInput register (3x)numbers, read-only3
holdingHolding register (4x)numbers, writable4
discreteDiscrete input (1x)bits, read-only1
coilCoil (0x)bits, writable0
"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.

KeyMeaning
idFrame id, decimal or "0x100". Up to 29 bits.
byteOffset into the 8-byte frame.
len1, 2 or 4. byte + len must not exceed 8.
scaleAs Modbus: the wire carries value × scale.
period_sHow 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:

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.

MessageFix
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

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.