Skip to content

Multi Driver

Core rule

Use exactly one of these forms in io.toml:

  • single driver: io.driver + io.params
  • composed drivers: io.drivers = [...]

Do not mix them in one file.

When multi-driver makes sense

  • one runtime needs both device-style and broker-style exchange
  • you are bridging multiple protocol planes into one process image
  • a gradual migration requires two transports during commissioning

Success means each driver has a separate purpose, no config mixes single-driver and multi-driver forms, and one process-image address is not silently owned by two paths.

Use the single-driver form first unless commissioning or migration really needs two planes at once.

The examples show when the extra complexity is justified and how to keep the config readable.

Example

This example shows how to run multiple I/O transports in one runtime via io.drivers = [...].

What you learn

  • how composed drivers are declared and validated
  • why production projects often need mixed protocols
  • why io.driver and io.drivers are mutually exclusive

Files in this folder

  • src/main.st: minimal %IX -> %QX logic (DO0 := DI0)
  • src/config.st: task binding plus VAR_CONFIG mapping (P1.DI0/P1.DO0)
  • io.toml: composed Modbus/TCP + MQTT profile
  • runtime.toml: runtime profile defaults
  • trust-lsp.toml: project profile

Step 1: Build PLC logic

Why: isolate source correctness from integration concerns.

cd examples/communication/multi_driver
trust-runtime build --project . --sources src

Step 2: Inspect composed driver config

Why: each transport has its own failure modes; composition should be explicit.

[io]
drivers = [
  { name = "modbus-tcp", params = { address = "127.0.0.1:1502", unit_id = 1, input_start = 0, output_start = 0, timeout_ms = 500, on_error = "fault" } },
  { name = "mqtt", params = { broker = "127.0.0.1:1883", topic_in = "trust/examples/multi/in", topic_out = "trust/examples/multi/out", reconnect_ms = 500, keep_alive_s = 5, allow_insecure_remote = false } }
]

Why this pattern:

  • Modbus handles deterministic register exchange.
  • MQTT handles broker/event style exchange.
  • One runtime can compose both process-image contributors.

Step 3: Validate composed form

Why: schema catches malformed driver entries and invalid mutually-exclusive configuration.

trust-runtime validate --project .

Step 4: Enforce mutual-exclusion rule

Why: mixed forms are ambiguous and rejected.

Do not mix these in one file:

[io]
driver = "simulated"
drivers = [{ name = "mqtt", params = {} }]

Use exactly one form:

  • single driver: io.driver + io.params
  • multi-driver: io.drivers = [...]

Step 5: Commission transport-by-transport

Why: debugging all transports simultaneously obscures root cause.

Recommended sequence:

  1. bring up Modbus endpoint and verify first,
  2. bring up MQTT broker and verify second,
  3. run both together and verify combined cycle behavior.

Common mistakes

  • composing drivers before validating each one independently
  • inconsistent timeout/reconnect settings across transports
  • forgetting safe-state policy while expanding transport scope
  • mixing io.driver and io.drivers

Full tutorial

This tutorial teaches how to configure and validate all major I/O backend forms:

  • loopback
  • simulated
  • gpio
  • modbus-tcp
  • mqtt
  • composed io.drivers = [...]

Why this tutorial exists

Many users only run simulated in early testing, then hit avoidable failures when moving to hardware or multi-protocol integration. This guide explains each form and what it is for.

What you will learn

  • when to use each backend
  • how to keep safe-state output policy across driver changes
  • how to validate io.toml before runtime start
  • why io.driver and io.drivers cannot be mixed

Prerequisites

  • complete Tutorial 13 first
  • optional hardware/network brokers for full runtime checks

Step 1: Prepare a sandbox project

Why: swapping backends repeatedly is easier in a disposable copy.

rm -rf /tmp/trust-io-backends
cp -R /tmp/trust-tutorial-13 /tmp/trust-io-backends
cd /tmp/trust-io-backends

Build once:

trust-runtime build --project . --sources src

Step 2: Start with loopback (local functional sanity)

Why: loopback is the fastest no-hardware path to confirm %Q writes can feed %I reads for local testing.

cat > io.toml <<'TOML'
[io]
driver = "loopback"
params = {}

[[io.safe_state]]
address = "%QX0.0"
value = "FALSE"
TOML

trust-runtime validate --project .

Expected result: - validation passes

Step 3: Switch to simulated (deterministic software I/O)

Why: simulated is useful when you want fake process behavior without physical pins or remote protocols.

cat > io.toml <<'TOML'
[io]
driver = "simulated"
params = {}

[[io.safe_state]]
address = "%QX0.0"
value = "FALSE"
TOML

trust-runtime validate --project .

Expected result: - validation passes

Step 4: Configure GPIO profile (hardware edge I/O)

Why: GPIO needs explicit IEC-to-pin mapping and debounce/initial state choices.

cat > io.toml <<'TOML'
[io]
driver = "gpio"

[io.params]
backend = "sysfs"
inputs = [
  { address = "%IX0.0", line = 17, debounce_ms = 5 }
]
outputs = [
  { address = "%QX0.0", line = 27, initial = false }
]

[[io.safe_state]]
address = "%QX0.0"
value = "FALSE"
TOML

trust-runtime validate --project .

Expected result: - config is schema-valid - runtime may still require platform permissions/hardware at run time

Step 5: Configure Modbus/TCP profile

Why: Modbus introduces transport, unit-id, and timeout policy decisions.

cat > io.toml <<'TOML'
[io]
driver = "modbus-tcp"

[io.params]
address = "192.168.0.10:502"
unit_id = 1
input_start = 0
output_start = 0
timeout_ms = 500
on_error = "fault"

[[io.safe_state]]
address = "%QX0.0"
value = "FALSE"
TOML

trust-runtime validate --project .

Expected result: - config validates - actual runtime connectivity depends on reachable Modbus server

Step 6: Configure MQTT profile

Why: MQTT requires explicit topic boundaries and reconnect behavior.

cat > io.toml <<'TOML'
[io]
driver = "mqtt"

[io.params]
broker = "127.0.0.1:1883"
topic_in = "line/in"
topic_out = "line/out"
reconnect_ms = 500
keep_alive_s = 5
allow_insecure_remote = false

[[io.safe_state]]
address = "%QX0.0"
value = "FALSE"
TOML

trust-runtime validate --project .

Expected result: - config validates - runtime connectivity depends on broker availability and ACLs

Step 7: Use multi-driver composition (io.drivers)

Why: production systems may need multiple protocol drivers in one runtime.

cat > io.toml <<'TOML'
[io]
drivers = [
  { name = "modbus-tcp", params = { address = "192.168.0.10:502", unit_id = 1, input_start = 0, output_start = 0, timeout_ms = 500, on_error = "fault" } },
  { name = "mqtt", params = { broker = "127.0.0.1:1883", topic_in = "line/in", topic_out = "line/out", reconnect_ms = 500, keep_alive_s = 5, allow_insecure_remote = false } }
]

[[io.safe_state]]
address = "%QX0.0"
value = "FALSE"
TOML

trust-runtime validate --project .

Expected result: - configuration validates as composed-driver form

Step 8: Understand the mutual-exclusion rule

Why: mixed driver forms are ambiguous and rejected.

Do not do this in the same file:

[io]
driver = "simulated"
drivers = [{ name = "mqtt", params = {} }]

Use exactly one form: - io.driver + io.params - or io.drivers = [...]

Step 9: Final verification command set

Why: standard validation loop catches most integration mistakes early.

trust-runtime build --project . --sources src
trust-runtime validate --project .
trust-runtime ctl --project . io-read

Common mistakes

  • forgetting io.safe_state for critical outputs
  • setting on_error = "ignore" during commissioning
  • validating config but not verifying real network reachability
  • mixing io.driver and io.drivers

Completion checklist

  • [ ] each backend form authored and validated
  • [ ] multi-driver form validated
  • [ ] safe-state policy preserved across changes
  • [ ] mutual-exclusion rule understood and enforced