Skip to content

Scripting with Rhai and Lua

The built-in stages (filter, sample, aggregate, project) cover the shapes most routes need, and they are the right tool when they fit. Scripting is the escape hatch for logic they don’t express: a derived engineering unit, a reading you only care about when it moves, a vendor payload that doesn’t match the southbound shape, a decision that depends on the relationship between several samples, or a KPI computed from several independent signals (multi-signal inputs).

The processor embeds two script engines, and a route picks one at runtime:

  • Rhai — a small, pure-Rust language; always compiled in and the default. Zero build toolchain, cross-compiles anywhere Rust does.
  • Lua 5.4 — the mature, widely-known language; available when the binary is built with the scripting-lua feature. A Lua script is also portable across the other language ports of the processor.

Either way a script is compiled once at startup and run per message on the route’s hot path, with no I/O, a sandbox, and a bounded operation budget — it can shape data but can’t reach outside the pipeline. This page is the complete guide: choosing an engine, the two roles a script plays, everything a script can see and must return, and a cookbook whose every example is shown in both engines (and is backed by a test in the processor’s own suite).

Select the engine per route with scriptEngine — a global default with a per-route override; the default is rhai:

"component": {
"global": { "defaults": { "scriptEngine": "rhai" } }, // applies to every route…
"instances": [
{ "id": "vibration", "scriptEngine": "lua", "subscribe": [""], "pipeline": [ /* Lua scripts */ ] }
]
}
  • The script dialect follows the engine. A route set to lua writes Lua; a route set to rhai writes Rhai. They are different languages — the cookbook below gives every example in both.
  • lua needs the scripting-lua build. Rhai is always available. Lua is compiled in only when the binary was built --features scripting-lua (release artifacts are); selecting lua in a build without it is a fail-fast startup error, not a silent fallback.
  • Which to pick? For typical scripts it barely matters — both are marshaling-bound and effectively tied. Lua (5.4) runs ~2–2.5× faster on heavy per-message array/compute (big reductions, RMS over large arrays); Rhai keeps the pure-Rust, zero-toolchain build. Pick Lua when a route does genuinely heavy math on high volume, or when you want scripts portable across ports; otherwise Rhai is the simplest default.

Both engines, both roles use the same scope. They differ only in what they return.

  • A filter script is a predicate — the message is kept when the result is truthy.
  • A script stage is a transform — it returns the new body, or a “nothing” value to drop the message.
{ "filter": { "script": "value > 40.0" } }
{ "script": "#{ \"tempF\": value * 1.8 + 32.0 }" }

Inline source suits a one-liner; anything longer belongs in a .rhai/.lua file referenced as {"script": {"file": "rules/derive.lua"}}, compiled and validated at startup (a missing file or a syntax error stops the component immediately). See Use an external script file.

Before each evaluation the engine binds two groups of variables — identical in both engines. The message view (the data in front of you) and the runtime context (constant facts about where the script runs).

Binding Type What it is
topic string the source topic the message arrived on
header map the envelope header — header.name (message type), header.version, header.timestamp (RFC3339, publisher time), header.uuid, header.correlation_id, header.reply_to
body map the full message body — body.signal, body.samples, or any JSON your payload carries
tags map the message-envelope business metadata (tags.site, tags.appId, …) — not the signal, and not the device (see identity)
identity map the source publisher’s UNS identity — identity.device, identity.component, identity.instance, identity.path; () when the message carries none
samples array body.samples (or empty); each element has value, quality, sourceTs, …
value any the first sample’s value (number, string, bool, or array)
quality string the first sample’s quality
thingName string the IoT Thing name ({ThingName})
componentName string the short component name ({ComponentName})
componentFullName string the fully-qualified component name
routeId string the id of the route running the script
recvMs integer this message’s broker receive time (Unix ms)

The whole message (header, body, tags, identity) is in scope, so you can branch on the message type, on the source device/adapter (identity.device / identity.component), carry a source id for dedup/tracing, or compare the publisher timestamp against recvMs. body always holds whatever arrived, so a script is payload-agnostic — read your own paths off body. (Note: thingName/componentName in the runtime context are the processor’s own identity; identity.* is the source publisher’s.)

Field access differs by language. Both see the same data; only the syntax changes — Rhai body.samples[0].value (0-based), Lua body.samples[1].value (1-based), and Lua arrays iterate with ipairs / length with #.

The return value is the whole contract — a script never mutates the message in place.

  • Filter → truthy keeps, falsy drops. In Rhai return a bool; in Lua, Lua truthiness (nil/false drop, everything else keeps). An error is treated as drop (logged) — a filter fails closed.
  • Transform → the new body: a map (Rhai #{…}) / table (Lua {…}) replaces body; the envelope is preserved. Return () (Rhai) / nil (Lua) to drop. A result that can’t convert to JSON, or a runtime error, also drops (logged).

A plain script stage sees one message at a time. Many industrial calculations don’t work that way: OEE, a ratio of two counters, an interlock over several states — the operands are independent signals, each arriving on its own cadence, and the result should refresh whenever any of them moves. The multi-signal form of the script stage does exactly that. The stage declares named inputs, caches the latest observation of each, and runs the script with the whole snapshot whenever a matched input’s value or quality changes:

{ "script": {
"file": "oee.lua",
"inputs": {
"running": { "device": "gw-fill-01", "signalId": "FillerRunning" },
"idealCycleS": { "device": "gw-fill-01", "signalId": "IdealCycleSeconds" },
"plannedRunS": { "device": "gw-fill-01", "signalId": "PlannedRunSeconds" },
"totalCount": { "device": "gw-fill-01", "signalId": "TotalBottleCount" },
"goodCount": { "device": "gw-fill-01", "signalId": "GoodBottleCount" }
},
"output": { "topic": "ecv1/gw-fill-01/telemetry-processor/oee/data/current" }
} }

Each selector names a signal (signalId/signalName), a topic filter (topic, with +/# wildcards — the way to bind identity-less publishers), and/or the source identity (device/component/instance, matched against the message envelope). The configuration reference has the full selector table and validation rules.

Two bindings join the ordinary scope (which still describes the triggering message):

Binding Value
inputs {name: {value, quality, timestamp, recvMs, topic}} — the current snapshot of every observed input. value/quality/timestamp come from the signal’s first sample (timestamp is the source timestamp; recvMs the broker receive time).
trigger {name, value, quality, timestamp, recvMs, topic} — the input whose change fired this evaluation.

The semantics, precisely:

  • Completeness is the script’s job, not the stage’s. By default the stage does not withhold evaluation for missing inputs: it runs the script on the first — and every — matched change, and an input that hasn’t arrived yet is simply absent from inputs. The script decides whether it has enough to compute and returns the drop value (() in Rhai / nil in Lua) otherwise. Check for an input with "x" in inputs (Rhai) or inputs.x ~= nil (Lua). This keeps the policy — which operands are essential, and what to do while you wait — in the script, where the domain logic lives.
  • Opt-in stage gating (required: true). If you’d rather the stage hold off until an operand exists, mark that input "required": true; the stage then withholds every evaluation until all such inputs have been observed. It’s a convenience for the common “need them all” case — the script is still free to guard beyond it (e.g. on quality). With no required input, the stage never gates.
  • Change detection. A message that repeats an input’s current value and quality refreshes its timestamps but does not re-evaluate. A quality flip alone (GOOD → BAD) is a change — the script decides what to do with a bad-quality operand (return the drop value to hold the last output).
  • Per-device isolation. The input cache is partitioned by the source device of the envelope identity: two lines publishing the same signal ids each get their own snapshot, and one device’s values can never leak into another’s calculation.
  • Consumed, not forwarded. A multi-input stage is a sink for its matched messages: the triggering message is consumed (its data lives on in the snapshot), and a subscribed message that matches no input is consumed silently. Put a multi-input script last in its pipeline.
  • Restart-empty. The cache is in-memory; after a restart each input re-initializes on its next message (an inputs-guarding script simply waits for what it needs, as it does at first startup).

The output side. Without output, the result replaces the triggering message’s body in place — which couples the derived value to whichever source topic happened to move last. A derived signal like OEE is its own signal with its own UNS topic, so the multi-signal form usually configures output.topic: every successful evaluation then publishes a new envelope there (header name ScriptResult by default, configurable), produced by the processor itself (identity instance = the route id) and carrying the triggering message’s uuid as its correlation_id for provenance. The route’s startup validation rejects an output topic on a reserved UNS class or one the route’s own subscribe filters would re-consume (a feedback loop), and the processor’s self-echo guard drops any re-consumed copy of its own output as a second line of defense.

A working OEE script over those inputs. Because the stage doesn’t gate, the script waits for its operands itself — a one-line guard. (Prefer "required": true on the five inputs if you’d rather the stage hold off until they exist; then you can drop the presence check.)

// Availability × Performance × Quality, refreshed on any operand change.
for k in ["running", "idealCycleS", "plannedRunS", "totalCount", "goodCount"] {
if !(k in inputs) { return (); } // wait until every operand exists
}
if inputs.running.value != true { return (); } // line stopped → hold the last value
let perf = (inputs.idealCycleS.value * inputs.totalCount.value) / inputs.plannedRunS.value;
let qual = inputs.goodCount.value / inputs.totalCount.value;
#{
"oee": perf * qual,
"performance": perf,
"quality": qual,
"basedOn": trigger.name // which operand refreshed this result
}

Each evaluation is a pure function of its bindings — no script variable survives between messages in either engine, which is what lets one engine instance serve a route safely. Cross-message state lives in the stages, not the script: sample for rate limiting, aggregate for windowed counters/min/max/avg, and the multi-signal script stage’s input cache for latest-value snapshots across signals. A common pattern is a script that shapes each message feeding an aggregate that accumulates.

Both engines are locked down to the same guarantees:

  • No I/O. Rhai has none by construction. The Lua engine loads only string/table/math (plus base functions like ipairs/pairs/tostring) and nils out os, io, package, require, load, debug — there is no ffi, no file, no network, no way to reach the host.
  • A bounded op budget. Rhai caps each evaluation at 1,000,000 operations; the Lua engine enforces the same via an instruction-count hook — a runaway loop (while true do end) is aborted and the message dropped, never hung. (Lua 5.4’s interpreter honours the count hook reliably, which is one reason it, not LuaJIT, is the embedded Lua here.)
  • Compiled once at startup; editing a script file needs a restart.

You don’t need to master either language — the subset here is small. Rhai is Rust/JS-like (#{} maps, || closures, let, 0-based arrays); Lua is {} tables, function, nil, 1-based arrays with ipairs/#. Both have if/for/while, user functions, arithmetic with int/float promotion, and the usual string/array helpers. The Rhai book and the Lua 5.4 manual have the full languages.

Real patterns — each with the goal, the script in both engines (pick a tab; the choice syncs across the page), and how/why it works. Every snippet is exercised by a test in src/proc/script.rs.

1. Derive an engineering unit, dropping empty reads

Section titled “1. Derive an engineering unit, dropping empty reads”

Goal: convert a raw Celsius reading to Fahrenheit, but drop a message that carries no sample.

fn to_fahrenheit(c) { c * 1.8 + 32.0 }
if samples.is_empty() { return (); } // no reading → drop
#{ "signal": body.signal, "tempF": to_fahrenheit(value) }

How & why. A helper function names the conversion; the guard runs first and drops a reading-less message before anything uses the (absent) value. Deriving units at the edge means the cloud stores query-ready values, not raw counts.

Goal: an array-valued signal (value = [10, 20, 30]); emit summary statistics.

fn mean(xs) {
if xs.is_empty() { return 0.0; }
let s = 0.0;
for x in xs { s += x; }
s / xs.len()
}
let peak = value[0];
for x in value { if x > peak { peak = x; } }
#{ "mean": mean(value), "peak": peak, "n": value.len() }

RMS is the same shape with a .sqrt():

let sumsq = 0.0;
for x in value { sumsq += x * x; }
#{ "rms": (sumsq / value.len()).sqrt() }

How & why. An array value is an ordinary array in either engine — index it, iterate it, take its length. Reducing at the edge turns a burst of numbers into the few figures an operator watches.

3. Rate of change across consecutive samples

Section titled “3. Rate of change across consecutive samples”

Goal: a batched message carries several samples; emit the deltas between consecutive readings.

let deltas = [];
for i in 1..samples.len() {
deltas.push(samples[i].value - samples[i - 1].value);
}
#{ "deltas": deltas }

How & why. Walk the samples from the second to the last and subtract the previous value. The built-in filters test one value at a time and have no notion of “the previous sample”; a rising rate is often the alarm, not the absolute value.

4. Keep only when an array crosses a threshold enough times

Section titled “4. Keep only when an array crosses a threshold enough times”

Goal: a filter that keeps a message only when at least two elements exceed 50.

value.filter(|x| x > 50).len() >= 2

How & why. “How many crossed the line” is exactly what the scalar built-in filters can’t express. A single spike might be noise; two or more is a signal.

5. A generic, reusable script that stamps identity

Section titled “5. A generic, reusable script that stamps identity”

Goal: one script, deployed to many components, that tags each message with where it came from — without hard-coding those values.

#{
"signal": body.signal,
"value": value,
"thing": thingName,
"component": componentName,
"route": routeId,
"ingestedMs": recvMs
}

How & why. thingName/componentName/routeId/recvMs come from the runtime context, not the message — so the same script text produces different identity on each device. That’s how you avoid a bespoke script per component.

6. Normalize a non-southbound vendor payload

Section titled “6. Normalize a non-southbound vendor payload”

Goal: reshape {"dev": "pump-7", "metric": "vibration", "raw": 325} into the southbound signal shape so the built-in stages and the file sink’s default projection work.

#{
"signal": #{ "id": body.dev, "name": body.metric },
"samples": [ #{ "value": body.raw * 0.1, "quality": "GOOD" } ]
}

How & why. One script stage at the front of a route normalizes an arbitrary payload into the shape everything downstream already understands — the payload-agnostic model in action.

Goal: translate a vendor status string into a compact numeric code.

let code = switch body.status {
"RUNNING" => 1,
"IDLE" => 0,
"FAULT" => -1,
_ => 99,
};
#{ "statusCode": code }

How & why. Rhai has a switch expression; Lua uses if/elseif. Numeric codes are cheaper to store and easier to threshold than free-text, and the fall-through case makes “unknown” explicit.

Goal: sum an array value.

#{ "total": value.reduce(|a, v| a + v, 0.0) }

How & why. Rhai’s reduce folds the array in one expression; Lua uses an accumulator loop (its standard library has no reduce). Both are one idea — accumulate across the elements.

9. Route by message type and carry provenance

Section titled “9. Route by message type and carry provenance”

Goal: keep only raw signal updates, and stamp each with the source id for dedup/tracing.

// filter:
header.name == "SouthboundSignalUpdate"
// transform:
#{
"signal": body.signal,
"value": value,
"msgType": header.name,
"sourceId": header.uuid,
"corrId": header.correlation_id
}

How & why. The message type (header.name) distinguishes a raw update from an aggregated ProcessedTelemetry; header.uuid/header.correlation_id are the ids the publisher stamped — first-class facts about a message, not implementation detail.

  • No script-held state — no script variable survives across messages; cross-message state comes from the stages (sample, aggregate, or the multi-signal input cache).
  • Fail-closed / drop-on-error — a filter that errors drops the message; a transform that errors or returns a non-JSON value drops it. Scripts never crash the route.
  • The op budget bounds each evaluation in both engines — deep loops over large arrays on every message add up; push heavy accumulation into aggregate.
  • No I/O; the Lua sandbox additionally removes os/io/load/package/debug (and there is no ffi).
  • Integer vs. float — a JSON 20 is an integer and 20.0 a float; multiply by a float when you need a float result from integer inputs.
  • 1-based Lua arrayssamples[1] is the first sample in Lua, samples[0] in Rhai.