I/O¶
How to author the bottom layer of the stack: the IOModule resources
that describe physical I/O hardware, and the SimulationPreset
resources that stand in for hardware on the simulator. Everything
above this layer (control modules, phases, recipes) is hardware-
agnostic. All the hardware variation lives here.
The whole point of this layering is that the phase templates, operations, unit procedures, and recipes don't change at all when you move from sim to real hardware. All the variation lives in the IOModule (or its simulation equivalent).
Integrators: see the API Reference for the REST equivalents of the actions on this page.
Protocol support and validation status¶
Cloud-Native DCS draws a sharp line between implemented and field-proven. A driver compiles, has unit tests, and looks plausible in a code review long before it has talked to a real PLC. We label each one explicitly so you can size the gap between "checks the box" and "trust this on a regulated batch."
Status legend
- Field-proven — exercised end-to-end against real hardware on the reference rig. Safe to deploy.
- Implemented-untested — code is in tree with unit tests and no metal proof yet. Treat as PoC-grade until validated for your device.
- Bridge — no native driver. Reach the device through an OPC UA gateway (Kepware, FactoryTalk Linx Gateway, vendor-native OPC UA server).
- Not supported — no driver, no bridge in tree.
By protocol
| Protocol | Status | Signal types proven | Reference rig | Caveats |
|---|---|---|---|---|
| Modbus TCP | Field-proven (DI/DO/AI/AO) | DI, DO, AI, AO | Wago 750-352 coupler + 750-430 DI + 750-454 AI + 750-554 AO + 750-530 DO | Analog proven end-to-end at B9 commissioning: a PID phase drove the 554→454 4-20 mA loop through a full recipe, tracking within one 12-bit LSB (seven-point sweep ±0.15 % of span; word encoding verified with non-trivial values). This lineup exchanges single 16-bit words, so metal has only ever exercised one register at a time. Wider values are supported — see 32-bit and wider values — and all four vendor layouts (ABCD, CDAB, BADC, DCBA) are proven in both directions against an independent Modbus implementation by pkg/driver/modbus/interop_test.go. No cross-register value has been read off a physical device. |
| OPC UA | Implemented-untested (proven end-to-end against the reference simulator; no metal proof yet — field validation pending design partner) | DI, DO, AI (read/write); subscribe path exercised via integration tests | Microsoft mcr.microsoft.com/iotedge/opc-plc (in-cluster Pod) |
Connect, ReadValue, WriteValue, and round-trip read-after-write all proven end-to-end against opc-plc on a reference cluster. Secured channels (Basic256Sha256 + Sign and SignAndEncrypt) and X509 client-cert auth land via #317 and are exercised by integration tests against opc-plc; field validation against a real PLC is still pending Phase 2. Trust-list inbox + cert auto-generation UX is tracked in #301. Browse/discovery surface is open: see #293 (data model) and #294 (UI). Reproducer: examples/opcua-poc/. |
| Simulation | Field-proven | DI, DO, AI, AO | in-cluster (no hardware) | Backs the entire riverbend reference simulation and screenshot pipeline. |
Modbus TCP and EtherNet/IP are plaintext protocols
They carry no authentication and no encryption, so any host that can
reach the I/O module can read and write process I/O. Production
deployments must compensate with network segmentation: a dedicated
field-bus segment reachable only from the device nodes, scoped by
networkPolicies.fieldBusCIDRs. The required zone/conduit controls
are spelled out in the
threat model.
Where the hardware offers it, prefer OPC UA with Sign or
SignAndEncrypt, the only field protocol here with built-in
integrity protection.
Not customer-ready¶
The drivers below compile and have unit tests but have not been validated
against real hardware. They are disabled by default. unit-runtime refuses to
load them unless started with --enable-experimental-drivers, and the gateway
UI does not offer them in the + Add IOModule form. They remain in the tree
so a partner with the relevant hardware can opt in and validate.
| Protocol | Status | Reason it's gated | Recommended alternative |
|---|---|---|---|
| EtherNet/IP | Experimental — implemented, untested, opt-in only | Generic Device Profile only (assembly-instance addressing). No vendor device has been validated. Does not support ControlLogix / CompactLogix CIP-tag addressing, which is a different protocol layer. | Bridge Rockwell PLCs through OPC UA via FactoryTalk Linx Gateway or Kepware. Tracking: #272. |
By vendor / device family
| Vendor / family | Recommended path | Status |
|---|---|---|
| Wago 750 series | Modbus TCP (native) | Field-proven for DI/DO/AI/AO |
| Siemens S7-1500 / S7-1200 | OPC UA (native server on the PLC) | Bridge, untested |
| Siemens S7-300/400, PCS 7 | OPC UA via Kepware or Siemens IDLink | Bridge, untested |
| Rockwell ControlLogix / CompactLogix | OPC UA via FactoryTalk Linx Gateway or Kepware | Bridge, untested |
| Rockwell Point I/O (1734-AENT) | OPC UA bridge (recommended) or EtherNet/IP generic | Bridge, untested; native EtherNet/IP is experimental and gated — see Not customer-ready |
| Beckhoff TwinCAT | OPC UA (native) | Bridge, untested |
| Schneider M580 / Modicon | Modbus TCP (native) | Implemented-untested for this family |
| Mitsubishi MELSEC | OPC UA via Kepware | Bridge, untested |
| Profinet devices | OPC UA via gateway | Bridge, untested |
| HART instruments | OPC UA via HART-IP gateway | Bridge, untested |
| DeviceNet, DH+, Foundation Fieldbus, Profibus PA | none in tree | Not supported |
| Direct CIP-tag addressing on ControlLogix | none in tree | Not supported (use OPC UA bridge or contribute a driver) |
If your device isn't here. This project is solo-maintained, with
paid engagements adding drivers as design partners come on board. If
your plant runs something not in the matrix, the realistic options
are: (a) front it with an OPC UA gateway you may already own
(Kepware is the industry default), or (b) contract a driver build
as part of an engagement. The pkg/driver/ interface is small
(Connect, ReadValue, WriteValue, Subscribe). A driver added
under engagement is delivered to the customer under the same license
terms as the rest of the codebase.
Sim vs real — the two layers that change¶
The entire hardware surface area lives in two places:
- The IOModule resource — switches from
protocol: simulationwith an inlinespec.simulationblock (or referencedSimulationPreset) toprotocol: modbus(orethernetip,opcua) with a real network address. - The ControlModule
tagBindings— these keep referring to IOModule channel names (analog.0,discrete.1, etc.). On sim those names resolved to virtual simulation slots. They now resolve to real hardware addresses. See Instances and tag bindings in Control Modules.
Everything above that (phase templates, operations, recipes, the state machine, the HMI, alarms, audit trail, batch records) is hardware-agnostic.
Authoring an IOModule¶
An IOModule has the same shape regardless of protocol. Only
protocol, address, the optional options block, and (for
simulation) spec.simulation differ. The example below creates a
simulated reactor IOModule as the reference plant ships it. Switch the protocol
field to modbus or opcua to drive real hardware, or ethernetip
if you've opted into experimental drivers (see Not
customer-ready). See
what changes between sim and real below for the
per-protocol differences.
/system → Site (left sidebar) → IO Modules sub-tab
→ + Add IOModule. Fill Name, pick a Controller
(the device node running the runtime), and pick a Protocol. The
address hint and protocol-specific options block update as you
change it, and selecting simulation auto-fills Address to
sim://local. Add channels in the channels table. Create.

sim://local auto-fill, channel tag bindings building live, and the created module reaching Online.
dcs --site riverbend apply -f iomodule.yaml
dcs --site riverbend get iomodules my-reactor-sim
# Status should transition Unknown → Online
# examples/riverbend/06-iomodules.yaml (simulated)
apiVersion: physical.dcs.io/v1alpha1
kind: IOModule
metadata:
name: reactor-sim
namespace: site-riverbend
spec:
controllerRef: pharma-controller
protocol: simulation
address: sim://local
description: "Simulated reactor I/O"
channels:
- name: DO0
direction: output
signalType: digital
address: discrete.0
- name: DI0
direction: input
signalType: digital
address: discrete.1
# ... 16 more analog + discrete channels ...
simulation:
# physics model from a SimulationPreset — behaviors drive analog
# channels from actuator commands, e.g. temp rises when jacket
# heats up. addressMap binds the preset's generic address names
# to this module's real tag addresses.
preset: jacketed-reactor
addressMap:
temperature_pv: reactor-sim:analog.0
jacket_sp: reactor-sim:analog.1
# ... one entry per generic address used by the preset ...
# (see Simulation Presets below)
Sim vs real — what changes¶
Switching the IOModule from sim to real hardware is a few-field edit on the same resource shape. The + Add IOModule form stays the same. The protocol dropdown decides which protocol-specific fields appear:

protocol: simulation, address: sim://local,
controllerRef: pharma-controller, inline spec.simulation
physics preset, and a single Tick Rate field. No protocol
options or diagnostic-address columns.

protocol: modbus (or ethernetip, opcua),
address: <ip>:<port>, no spec.simulation block, an options
block (unitID, timeout, byteOrder, wordOrder), and per-channel
diagnosticAddress for hardware wire-break / channel-fault bits.
controllerRef is optional for these network protocols (ADR
0021). A network-reached device is read over the wire by an existing
runtime node and owns no compute node of its own, so you may leave
controllerRef unset. The module is then monitored by a
namespace-shared network-io-probe pod. Set controllerRef only
when you want the module pinned to a
specific device node's probe. controllerRef remains required for
protocol: simulation, whose liveness comes from the simulation
Controller's heartbeat.
On a zoned OT network the shared probe's default placement is not
enough: only the nodes carrying field-network interfaces can reach
the device, and a probe scheduled elsewhere reads the module
Offline with nothing wrong except placement. Declare
spec.probePlacement.nodeSelector with the reach label your
deployment applies to those nodes (ADR 0042). Modules sharing a
selector share one probe pod, and the scheduler moves it to another
matching node if its current one dies. The field is refused on a
module that names a controllerRef, where the probe is already
pinned to the Controller's node.
The channel address field stays the same conceptual shape
(<type>.N) but now maps onto real hardware registers. For Modbus the
type names the register class directly: coil.N (writable coils),
discrete.N (read-only discrete inputs), and holding.N / input.N
(analog holding/input registers). There is no analog.N on Modbus.
For EtherNet/IP the address maps to a specific assembly slot, and for
OPC UA to a NodeId. The driver translates.
The same example as above, this time running against a Wago 750-series I/O coupler over Modbus TCP on a real plant network:
Same + Add IOModule form as above, but pick Protocol
modbus, paste the device's <ip>:<port> into Address, and
pick the controller node that's wired into the device subnet. The
protocol-specific options block (unitID, timeout, byteOrder,
wordOrder) appears under the address row.

dcs --site newark-plant apply -f iomodule.yaml
dcs --site newark-plant get iomodules reactor-di
# Validated=true means the observed channels match the spec
# examples/newark-plant/11-iomodules.yaml (real hardware)
apiVersion: physical.dcs.io/v1alpha1
kind: IOModule
metadata:
name: reactor-di # name change is optional; keep if helpful
namespace: site-newark-plant
spec:
controllerRef: plc-reactor-1 # the controller node running the runtime
protocol: modbus # was: simulation
address: 192.168.10.20:502 # was: sim://local
description: "Reactor digital inputs"
channels:
- name: DI0
direction: input
signalType: digital
address: discrete.0 # addresses relative to this module
diagnosticAddress: discrete.1 # Wago wire-break diagnostic bit
- name: DI1
direction: input
signalType: digital
address: discrete.2
diagnosticAddress: discrete.3
# ... real channels as wired on the physical coupler ...
options:
unitID: "1" # Modbus slave ID
timeout: "500ms"
# NO spec.simulation block — this is real hardware
32-bit and wider values¶
Modbus exchanges 16-bit registers, and it says nothing about what a pair of them means together. A device that reports a flow rate as a 32-bit float puts it in two consecutive registers, and its manual is the only place that says which of the two comes first, and which byte comes first inside each one. Nothing on the wire carries that. So the address says how wide the value is, and two options on the IOModule say how the device lays it out.
A bare address is one unsigned 16-bit register. That is what every address written before the width suffix existed meant, and it still means exactly that. No existing IOModule changes behaviour. A width suffix goes on the end, after a colon:
Channel address |
Registers read | Value |
|---|---|---|
holding.100 |
100 | unsigned 16-bit |
holding.100:uint16 |
100 | the same thing, spelled out |
holding.100:int16 |
100 | signed 16-bit |
holding.100:uint32 |
100-101 | unsigned 32-bit |
holding.100:int32 |
100-101 | signed 32-bit |
holding.100:float32 |
100-101 | IEEE-754 single |
holding.100:uint64 |
100-103 | unsigned 64-bit |
holding.100:int64 |
100-103 | signed 64-bit |
holding.100:float64 |
100-103 | IEEE-754 double |
input.N takes the same suffixes and stays read-only. A coil or a
discrete input carries a single bit, so a suffix on one is refused.
The layout is two independent choices, and both are properties of the device:
| Option | Values | What it changes |
|---|---|---|
wordOrder |
highFirst (default), lowFirst |
Which register holds the high half |
byteOrder |
big (default), little |
Byte order inside each register |
Vendor manuals usually print the four combinations as ABCD, CDAB,
BADC and DCBA. Here is 123.456 as a float32, so you can match a
manual against a capture:
| Manual says | byteOrder |
wordOrder |
Registers on the wire |
|---|---|---|---|
ABCD |
big |
highFirst |
0x42F6 0xE979 |
CDAB |
big |
lowFirst |
0xE979 0x42F6 |
BADC |
little |
highFirst |
0xF642 0x79E9 |
DCBA |
little |
lowFirst |
0x79E9 0xF642 |
The defaults are standard Modbus order and are what the reference rig
was commissioned on, so a module that names neither key keeps the
encoding it has today. Note that byteOrder applies to single registers
as well: setting it to little changes what holding.100 reads, and
what holding.100:float32 reads with it.
Getting either one wrong produces a wrong number and no error. The
device answers, the registers arrive, and they assemble into
something. A reading that is enormous, near zero, or jumps between two
unrelated magnitudes as the process moves is almost always word order.
Read the two registers one at a time with dcs io read <module>
holding.100 and holding.101 (bare addresses that come back as raw
16-bit words), and compare them against the table above.
Diagnose word order on an input. A bare address is read exactly as
written. That is what makes it the right probe here. It is also why it
is the wrong place to read an output on a device whose read and write
address tables differ. A WAGO coupler answers a read of an output's
write address from the analog input image, so the two registers you
compared would belong to a different card
(#1689).
Read an output back through its channel name, or at the channel's
declared readbackAddress.
An option this driver does not implement is refused when the module is built, with the accepted keys named in the error. A typo and a capability that does not exist are the same thing to a driver, and both used to be dropped in silence.
Where an output reads back¶
A Modbus address is a number, and nothing in the protocol says that the
number you write is the number you read. Some couplers publish two
address tables. A WAGO 750-series coupler is one: an analog output
written at holding.0 reads back at holding.512, and reading it at
holding.0 returns the analog input process image instead. The
manual states it in prose, as an offset of 0x0200 added to the address
to read an output back.
Left alone that is an invisible fault. Both areas carry the same
left-justified 0-32767 span. The value is therefore in range, its
quality is Good, and nothing reports an error. What you see is a
plausible number belonging to a different card
(#1689).
A channel therefore declares where it reads back:
channels:
- name: AO0
direction: output
signalType: analog
address: holding.0 # what a write targets
readbackAddress: holding.512 # what a read comes from
Auto-discovery fills this in for you. The WAGO device profile emits a
readbackAddress for every output it discovers, so a module discovered
by a version that knows about the split already carries it. Leave it
unset for a device whose outputs read back in place, which is most of
them.
A module whose channels were discovered before that carries none, and
none of it looks wrong. Its outputs are still read at their write
addresses, which is the fault this section opens with. Upgrading on its
own does not move them. The product reports the disagreement in two
places. An output whose device declares a readback address its channel
does not carries a note under its address in the channel table, naming
the register and asking for a fresh discovery. The IOModule reports
status.validated: false for as long as the two disagree.
The remedy is Discover Channels on the IOModule, which rescans the
device and rewrites spec.channels with the addresses it reports. Do it
once per module after an upgrade that first taught the product about the
device.
Two things behave the way they read. A channel name is the
abstraction over the channel, so dcs io read <module> AO0 follows the
readback address and reports what the output is actually holding. A
bare address names one register and is never redirected, so
dcs io read <module> holding.0 still reads exactly that register.
That is what makes the bare form the right tool for telling the two
tables apart on a device you are commissioning.
The readback trails the write. On the WAGO coupler an output write shows up at its readback register within about 10 ms. A read issued in the same breath as the write can still answer with the pre-write value. Two CLI invocations are far enough apart that a person never meets this. A program that verifies its own write in the same code path has to allow for it.
What kind of device is at the far end¶
The protocol says how the product reaches a box. It has never said what
the box is, and the difference matters: a Wago coupler carries the
channels you wired into it, while a Siemens S7 at the same Modbus
address is running its own program that nobody here authored. Both are
IOModule resources, because both are endpoints the runtime exchanges
process data with, and the optional spec.fieldDevice record is what
tells them apart.
spec.fieldDevice.type |
What it means |
|---|---|
io-module |
A passive card, coupler or remote-I/O head. It runs no program; its channels are the process data. |
control-device |
A peer controller running its own program that the product neither authors nor executes. Tags are exchanged as equals. |
instrument |
A smart instrument or analyzer that speaks a network protocol in its own right, with no coupler in front of it. |
gateway |
A protocol converter or aggregating server fronting other devices. The tags are real; the box at the address is not the box the signal came from. |
The record also carries vendor, model, serialNumber and
firmware. That is the nameplate a maintenance engineer wants when a
device stops answering. Vendor and model name the product. The serial
number names the one unit standing in the plant, which is what a work
order is opened against. Nothing in the runtime, the drivers or the
recipe vocabulary branches on any of it. Classifying a device changes
what the product can tell you about it and never how a tag is read.
The classification is declared and never inferred. A module named
siemens-plc-01 is not a control device until somebody says so, and an
IOModule with no fieldDevice block reads as Unclassified in the
Device column of the IO Modules sub-tab. Nothing sorts it
into a bucket on the strength of its name. Filling the block in is the
fix. Every IOModule authored before the field existed is unclassified,
which is honest: nobody has looked at those boxes yet.
Two rules bound what a device record is for (ADR 0033):
- The box stays out of the ISA-88 process tree. Its tags enter it.
A field device's tags become
ControlModuleinstances, exactly as device discovery already emits them. Classifying a box gives it no state machine, no phases, and nothing a recipe binds to. - One physical thing gets one record. A vendor skid orchestrated
through
Unit.spec.serviceBindingis already a citizen of the process tree, so it needs no device record of its own. Where an IOModule addresses the same host as such a Unit, the reconciler says so onstatus.conditions[type=DistinctAssetRecord]and the row is marked already in the process tree: the module is that skid's data plane, and the Unit is the box.
Worked examples of all four types, including the skid case, are in
examples/field-devices/iomodules.yaml.
A smart instrument's own identity¶
An IOModule's record describes the box the runtime opens a connection
to. A smart instrument reached through a shared endpoint is not that
box. Its nameplate lives on the ControlModule that models it, in a
spec.fieldDevice record of the same shape
(ADR 0043).
Device discovery writes one for every PA-DIM
instrument it emits, and the module's Device section reads it back
and edits it.
Both records are served on their resource's API, which is what lets a maintenance system resolve a device-health alarm to a physical asset: the alarm names a control module, the control module names a serial number, and the work order is opened against that. The serial is the field the two records never share. A shared endpoint's IOModule carries the instrument's vendor and model when there is exactly one device behind it, and never its serial, because one unit identity recorded in two places is one that can drift.
What stays identical above the IOModule¶
ControlModule instances bound to this IOModule keep their
tagBindings strings identical between sim and real. Only the
IOModule name portion changes (reactor-sim:discrete.1 becomes
reactor-di:discrete.1). The addressing convention
<iomodule-name>:<channel-address> is the same on both sides. See
Instances and tag bindings
in Control Modules for the binding spec.
Every layer above the ControlModule (phase templates, operation templates, unit procedure templates, master recipes) is identical bit-for-bit between sim and real. That's what makes the riverbend reference simulation a legitimate starting point for a real plant.
End-to-end bring-up workflow¶
The clip below walks the configured result on the reference fermentation
site: an IOModule's channels and protocol addressing, the tag-address
binding strings control modules consume, live channel values arriving
through the io-probe, the inline spec.simulation binding, an audited
edit, the driver's connection and its configuration state on the
Diagnostics page, and the same signal landing on the operator's HMI
faceplate.
/system: channels and addressing, live io-probe values, the simulation binding, one audited edit, driver health, then the same signal on the /hmi faceplate.The typical workflow for enrolling a new piece of real equipment:
-
Enroll the controller device. Follow the Device Enrollment guide: join the device to the cluster at the deployment layer, then register it. This gives you a
Controllerresource bound to a matching Kubernetes node. -
Author the IOModule. Write a YAML file describing the physical I/O: protocol, network address, channel list with real addresses. For Wago Modbus, each channel's
addressis the relative coil or register offset within the coupler's address space. For Rockwell EtherNet/IP point I/O, it's the assembly slot. For OPC UA, it's the node path. -
Apply the IOModule. The physical-operator reconciles the IOModule and the unit-runtime on the target controller attempts to open a connection.
/system→ Site (left sidebar) → IO Modules sub-tab → + Add IOModule. The same form as in Authoring an IOModule. Fill in the fields from theiomodule.yamlyou authored in step 2 (protocol, network address, the channel list with real addresses). Create. TheSTATEcolumn transitionsUnknown→Online.
dcs --site newark-plant apply -f iomodule.yaml dcs --site newark-plant get iomodules reactor-di # Status should transition Unknown → Online # Validated=true means the observed channels match the specApply the
iomodule.yamlauthored in step 2:dcs --site newark-plant apply -f iomodule.yaml -
Author ControlModule instances and run a phase against them. Each piece of field equipment (valve, sensor, motor) gets its own
ControlModulewithtagBindingsthat point at IOModule channels. Once those are live, aMasterRecipecalling an existing phase template runs against real hardware without modification. See Instances and tag bindings in Control Modules for the full instance-authoring flow, and Recipes for the recipe layer above. -
Wire alarms. Copy the riverbend alarm patterns for this unit. Discrete-valve MISMATCH alarms, IOModule
StateEquals: Fault, hardware-limit alarms on safety-critical PVs. See Alarms and Interlocks for the full patterns, and tunedebounceSecondsto match your real valve travel times.
Simulation Presets¶
Simulation Presets are reusable equipment-behaviour models with generic
address names (e.g. temperature_pv). A simulation IOModule references
a preset and supplies an addressMap to translate the generic names to
real I/O addresses. The unit controller expands the preset at reconcile
time and forwards the flattened behaviours to the runtime, so the same
virtual equipment can back multiple simulation or validation runs without a
per-instance CRD.
The clip below walks the whole virtual-commissioning loop on site plant-01: authoring a preset for a utility skid that does not physically exist (behavior types and parameters typed in the browser), binding it onto a simulation IOModule's channels through the address map, and watching the live channel values flip from placeholder wobble to the authored process.
/system: author the glycol-loop preset, map it onto glycol-sim, and the plant behaves, with no hardware anywhere in frame./system → Equipment Library → Simulation Presets
→ pick a site. The list shows each preset's behavior count
and the IOModules that reference it.
Create: click + New Preset. Enter a name and description,
then click + Add Behavior for each behavior row. Each row takes
a generic address name (e.g. temperature_pv), a behavior type from
the dropdown, and a comma-separated key=value parameter list.
Click Save.
Edit / Delete: click a preset to open its detail view. The ✎ Edit and ✕ Delete buttons are in the top right. Editing an in-use preset takes effect on the next reconcile of each referencing IOModule.
Reference from an IOModule: open a simulation IOModule
(protocol: simulation), click Edit, and use the Simulation
section. Pick a preset from the dropdown, then map each of its
generic addresses onto a real channel address on this module (the
field suggests the module's own discovered channel addresses). The
form requires every preset address to be mapped before Save.
An unmapped generic address would resolve to a dead tag at runtime.
Inline behaviors and faults authored via dcs apply carry through
the edit unchanged.

dcs apply -f reactor-sim.yaml
dcs get simulationpresets -s $SITE
apiVersion: physical.dcs.io/v1alpha1
kind: SimulationPreset
metadata:
name: reactor-sim
namespace: site-demo
spec:
description: "Jacketed reactor with temperature and level dynamics"
behaviors:
- address: temperature_pv
type: PIDResponse
params:
coAddr: jacket_sp
gain: "1.0"
timeConstantSec: "300.0"
engMin: "0"
engMax: "150"
- address: level
type: TankLevel
params:
inFlowAddr: inlet_flow
outFlowAddr: outlet_flow
capacity: "1000.0"
maxFlowRate: "1.0"
Behavior type values come from the SimBehaviorDef enum: SineWave,
RandomWalk, NoisyConstant, DigitalPulse, TemperatureRamp,
ValveFeedback, TankLevel, PIDResponse, Expr. Each type takes its
own parameters. See the built-in presets for idiomatic examples. Every
behavior stores its output in engineering units by default, so the channel
table reads believable values. Add raw: "true" to a behavior's params to
model raw ADC counts (0–65535 scaled from engMin/engMax) when you want an
AI block to exercise a real raw-to-engineering conversion.
A parameter you leave out takes the behavior's documented default. A
parameter set to a value that does not parse is refused when the preset or
the IOModule is saved, and the refusal names the parameter. Defaulting it
would run the tag on a number nobody wrote. An AI block also auto-discovers
its scaling from engMin/engMax, so such a mistake reaches a block whose
own configuration is correct. See
ADR 0064.
See Simulation profiles in the examples library for complete preset definitions.
Protocol cheat sheet¶
| Protocol | Driver | address format |
Channel address | Common quirks |
|---|---|---|---|---|
| Modbus TCP | pkg/driver/modbus |
<ip>:<port> |
coil.N (coils), discrete.N (discrete inputs), holding.N (holding registers), input.N (input registers). A holding or input register takes an optional width suffix — holding.N:float32, input.N:uint32 — covering uint16, int16, uint32, int32, float32, uint64, int64 and float64; see 32-bit values |
Unit ID via options.unitID. Register order for a value wider than one register via options.wordOrder, byte order within a register via options.byteOrder. An option this driver does not implement is refused outright. |
| EtherNet/IP (experimental — gated) | pkg/driver/ethernetip |
<ip>:<port> |
Assembly slot addresses (vendor-specific) | Generic Device Profile only. ControlLogix tags need CIP addressing. Explicit messaging only, so options.timeout is the single key it reads and an rpi has nothing to set. An option this driver does not implement is refused outright. Disabled by default. Requires --enable-experimental-drivers on unit-runtime and direct CRD authoring (no UI form). See Not customer-ready. |
| OPC UA | pkg/driver/opcua |
opc.tcp://<host>:<port> |
ns=<ns>;s=<nodeId> or browse path |
Channel security via options.securityMode (None, Sign, SignAndEncrypt) and options.securityPolicy (a policy short name such as Basic256Sha256, or its full URI); user auth via options.username/options.password, certificates via options.certFile/options.keyFile/options.caFile, and the two bounds via options.connectTimeout/options.requestTimeout. A value that does not name a mode or a policy this client implements is refused. A defaulted typo would have silently meant no security at all (see ADR 0057). An option this driver does not implement is refused outright. Subscriptions are push-based. |
| Simulation | pkg/driver/simulation |
sim://local |
analog.N, discrete.N |
Inline spec.simulation block on the IOModule drives virtual values. See Simulation profiles. options.tickRate sets how often behaviors run, options.seed makes the run reproducible, and init.<address> seeds one address at startup. An option this driver does not implement is refused outright, and so is a seed that would not seed. A behavior or fault parameter set to a value that does not parse is refused the same way, at the document (ADR 0064); an init.<address> value is not, because an address may hold a string. |
Common issues¶
| Symptom | Likely cause | Fix |
|---|---|---|
IOModule stuck in Unknown |
The io-probe did not answer for it, so nothing has measured the device. The probe pod may be missing, still starting, unschedulable, or terminated by a node restart | Check the probe pod before the device. status.probePodName on the IOModule names the pod that serves it, so read that field. A module with a probePlacement is served by a hashed sibling of network-io-probe, and a guessed name would be the wrong pod. Then kubectl -n site-<site> get pod <that name>. Unknown says nothing about the device, and a unit runtime scanning the same device keeps working throughout |
IOModule in Fault |
The probe reached the device's endpoint and was refused | Check firewall / VLAN, verify the controller node has network access to the device subnet, ping the device from the controller host |
IOModule in Offline |
The Controller is missing, is not Joined, or its node is not Ready |
dcs get controllers — the device node itself is the problem, and the I/O is fine |
dcs io read / dcs io write fails with an io-probe pod error |
The probe pod is not serving. It is a monitoring sidecar, so the device is usually fine | The error names the pod and its phase. The operator reconciles a terminated probe pod away within a minute; if one persists, kubectl -n site-<site> delete pod <name> forces the replacement |
dcs io write refused with a conflict naming a running unit |
A Unit whose control modules read and write through this IOModule is executing, and a raw channel write carries no ISA-88 equipment mode for the gateway to consult, so it is refused for the duration | Write the tag through its control module instead: dcs mode the module to Manual and write the tag, which is the modelled path ISA-88 Table 1 describes. The refusal names the unit and the control module that binds the module, so dcs get units <name> shows what is holding it |
dcs io read / dcs io write says the IOModule names no io-probe pod |
Nothing has resolved a probe for it: the controllerRef does not name an existing Controller, that Controller has no probe pod yet, or physical-operator is not running |
dcs get iomodules <name> -o yaml and check status.probePodName. An empty value is the operator's own answer. Fix what it points at (dcs get controllers, then the operator's pod) |
IOModule Online but Validated: false |
Declared channels don't match discovered channels | Run dcs get iomodules <name> -o yaml and compare spec.channels vs status.channels; align your spec with what the device actually exposes |
| ControlModule tag reads garbage values | Channel address is wrong, or byte order / scaling doesn't match the device | For analog inputs, check the driver's raw value in the runtime logs against the device's web interface; for scaled values, check the analog-sensor template's raw_min/raw_max/eng_min/eng_max parameters. On a 32-bit Modbus value, suspect register order first — see 32-bit and wider values |
| Valve CMD changes but FB never updates | Digital output wired to the wrong coil, or missing ground | Toggle from the faceplate and measure at the field terminal with a multimeter |
| MISMATCH alarm fires constantly | debounceSeconds too short for this specific valve's travel time |
See Alarms and Interlocks — Tuning debounce for real equipment |
What the device does when the controller dies¶
Everything else on this page assumes something of ours is running. This section is about the case where nothing is.
Both fail-safe mechanisms the product ships are executed by a process that no
longer exists at the moment they are needed. A failState on an output block is
written by the runtime after its scan loop stops, and the armed hold is a timer
inside that same runtime. Neither survives the machine losing power. The only
thing that can act then is the device itself.
spec.failSafe declares what it should do.
spec:
protocol: modbus
address: 10.10.20.51:502
failSafe:
action: clear # hold | clear
timeout: 60s # how long with no traffic before the device decides
recovery: latch # latch | resume
hold leaves every output at its last commanded value. clear drives them to
the bottom of their range after timeout.
timeout has a floor of 45 seconds and admission refuses anything shorter. The
reason is the second surprise below: our own
traffic feeds the watchdog, so a shorter deadline cannot notice us leaving any
sooner, and all it adds is a trip when one read runs late.
Four things that surprise people¶
A cleared 4-20 mA output settles at 4 mA. Raw zero on a 4-20 mA card is the
bottom of the span. NAMUR NE 43 reserves 3.6 mA and below, and 21 mA and above,
for faults. A cleared analog output is therefore a perfectly valid 0% reading,
and a receiving instrument cannot tell it from a controller deliberately
commanding zero. Nothing downstream will alarm on it. That is why recovery
defaults to latch: the device refusing further process data is what drives the
IOModule to Fault and tells the plant.
The timeout measures whether the product is still talking to the device, not
whether control is still writing outputs. Any traffic resets it. So it fires
when the controller node dies and takes the runtime and the io-probe with it,
which is the case this exists for. It does not fire when the node lives and only
the program stops, and it does not need to: that is what a block's failState
covers.
That is also where the 45-second floor comes from. The io-probe reads every
device it serves once every 15 seconds. That read is traffic like any other, so a
deadline shorter than the cadence is reset before it can ever expire on purpose.
A 2s timeout does not give you a two-second response. It gives you a node that
clears its outputs the first time one read runs late. 45 seconds is three probe
cadences, which is the convention PROFINET derives a device watchdog from and CIP
spells as RPI times a multiplier.
Clearing the floor is not the same as having margin on your plant. The probe loop
is serial, so an unreachable module on the same Controller costs every module
behind it a dial timeout. The cadence a device actually receives can therefore be
much longer than 15 seconds. The FailSafeTimeoutMargin condition measures the
gaps each device really sees and reports whether the declared timeout clears
them. It refuses nothing. The device is already armed, and disarming a plant's
protection because our own loop went slow would be the wrong repair.
What a device can do is per model, and Modbus standardises none of it.
PROFINET carries substitute values and CIP carries a per-channel Fault Action,
both delivered by the protocol itself. Modbus carries nothing, so every vendor
does its own thing. A WAGO 750 coupler acts on the whole node, its only action is
to clear, and it has no substitute-value register at all. A declaration a device
cannot honour is refused. The refusal reaches status.failSafe.error and the
FailSafeApplied condition.
Leaving it out is a choice the product will keep asking you about. An unset
spec.failSafe writes nothing to the device, so an upgrade never changes what a
running plant does. The module then keeps whatever its device shipped with. For a
factory WAGO coupler that is no watchdog at all, so the outputs hold their last
commanded value. The FailSafeDeclared condition reads False until you say what
you want.
Reading what the device actually holds¶
status.failSafe is read back from the device. The spec is never echoed into it.
That is the only way to tell a hold somebody chose from a hold a coupler shipped
with, because those are the same observation at the field terminals.
dcs io list carries it as a column, so a whole site answers at once. dcs get
iomodules prints the same column.
$ dcs io list -s plant-01
NAME CONTROLLER PROTOCOL STATE CHANNELS FAIL-SAFE
wago-750-352 bench-plc modbus Online 12 clear 1m0s
turck-tben bench-plc modbus Online 8 hold (undeclared)
fermenter-sim sim-plc simulation Online 6 n/a
The column says what the device does, and marks anything the record gets wrong after it. Six answers are possible.
| Cell | What it means |
|---|---|
clear 1m0s, hold |
The device holds what somebody declared |
hold (undeclared) |
The device holds its last commanded value and nobody chose that. This is what the cold-boot drill measured |
hold (declared clear) |
The record and the hardware disagree. Worth paging on, because the module claims protection the plant does not have |
n/a |
This device has no comm-loss configuration to hold. The cell describes the device and reports no failure |
unknown |
Nothing has read the device. It is never rendered as hold, because a configuration nobody has read is not a fail-safe anybody chose |
clear 1m0s (fires on a routine gap) |
The device holds exactly what was declared, and the deadline is shorter than the gaps this device actually sees between telegrams. The watchdog is therefore armed against our own slow cycles |
Naming one module prints the same answer in full, and it carries the reconciler's own verdict. Nothing paraphrases it.
$ dcs io list wago-750-352 -s plant-01
Name: wago-750-352
Controller: bench-plc
Protocol: modbus
State: Online
Fail-Safe (what the device does when the controller dies)
Declared: clear after 1m0s, recovery latch
Device: clear after 1m0s, recovery latch
Verdict: the device is configured to clear its outputs to the bottom of range after 1m0s, then latch
Margin: the declared 1m0s clears the worst 15s this device recently went between reads
The gateway prints the same column in the Diagnose panel, in the I/O Drivers table of a unit runtime's Runtime tab. It sits beside Connected and Configuration because neither of those can say it. A connected driver whose configuration applied cleanly is green on every other column of that row while the coupler behind it holds a live output with nothing running.
The raw status is there for a script.
$ kubectl get iomodule wago-750-352 -o jsonpath='{.status.failSafe}'
{"action":"clear","timeout":"1m0s","recovery":"latch","supported":true}
A FailSafeApplied condition of False with reason Drifted is the same state
the (declared clear) marker reports, and a FailSafeDeclared condition of
False is the same state (undeclared) reports. A FailSafeTimeoutMargin of
False with reason NoMargin is the same state the (fires on a routine gap)
marker reports, and its message names both numbers: how long this device recently
went between reads, and the deadline it was declared with.
Declaring one from the UI¶
The IOModule detail page carries the declaration alongside the device's own answer. Open the module under System -> Site -> Infrastructure -> IOModules and its Fail-Safe panel reports what the outputs will do, what the record says, and what the reconciler makes of the pair. An undeclared module says so in the reconciler's own words and points at the button that answers it.
Edit turns the panel into the editor. Action offers hold and clear,
and picking clear reveals Timeout and Recovery. A hold node has no
watchdog for either of them to configure. Device now stays beside the
controls throughout, because a declaration and a coupler's actual configuration
are the same picture at the field terminals until something shows both. Save
writes the declaration, and the io-probe applies it to the device on its next
pass.
Three things the form will not do.
It will not remove a declaration. Clearing spec.failSafe writes nothing to the
device, so a coupler somebody armed stays armed while the record forgets it.
Every surface then reports clear 1m0s (undeclared) for as long as the module
exists. Declaring hold is the way back, and it disarms the watchdog for real.
It will not offer substitute. No device the product addresses can apply a
substitute value, and a word the hardware cannot honour would be a declaration
that fails on every module that used it.
It will not accept the declaration on a module a running unit is addressing. The change reaches the coupler on the io-probe's next pass, which would alter what that unit's outputs do mid-batch without the unit noticing anything happened. Hold or stop the unit first, the same as for a channel remap.
Everything the form writes is a normal audited API write, so the declaration lands in the audit trail with the engineer who made it.
Recovering a node whose fail-safe fired¶
Measured on a live WAGO 750-352 during the #1683 bench acceptance and the #1700 follow-up, with a multimeter at the 750-554 terminals. None of it is in either manual.
A node that latched refuses its own repair. After a latch timeout the
coupler answers illegal data value on the watchdog time and mask registers,
and server device failure on process data. So a recovery cannot start by
tidying registers. It has to stop the watchdog first, restore what it wants,
and stop it again at the end, because after a stop the next write restarts the
timer. Reads never restart it, which is what makes verifying a recovery
possible.
One write gets the outputs back, and the readback is what lags. The coupler
takes the first write after a fail-safe fired. The true output register shows
the commanded value within about 10 ms, and often on the very next read. A read
issued in the same breath as the write can still answer with the pre-write
value. Twenty ordinary writes with no watchdog anywhere in the picture measure
the same spread, so the first write after a fail-safe behaves like any other
write. An earlier reading of this same observation had the coupler discarding
the first write, with a second write half a second later doing the work. That
was a retry loop sleeping half a second and then reading back the value its own
first write had already placed. A caller that verifies a write by reading it
back has to allow
for the lag. dcs io write and a read are separate requests, so a hand restore
during commissioning is past the lag before it reads anything.
Ordinary control writes do not re-arm a stopped watchdog. This is the one
that would make hold a lie, so it was measured directly: with the watchdog
verified stopped, a single output write left the status register at zero. A
node declared hold stays disarmed while the plant writes to it.
Related Documentation¶
- Device Enrollment — the step-zero flow for getting a controller node joined and registered.
- Control Modules — author the templates and instances that consume these IOModule channels.
- Parameter Binding — the full chain from a recipe parameter down to the IOModule channel.
- Alarms and Interlocks — wiring best-practice alarms for real equipment.
- Simulation profiles in the examples library — the sim variant you're replacing, as a reference for what the simulated unit did.
- ADR 0068 — why the product reports an undeclared device fail-safe and leaves the hardware alone.