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-luafeature. 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).
Choosing an engine
Section titled “Choosing an engine”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
luawrites Lua; a route set torhaiwrites Rhai. They are different languages — the cookbook below gives every example in both. luaneeds thescripting-luabuild. Rhai is always available. Lua is compiled in only when the binary was built--features scripting-lua(release artifacts are); selectingluain 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.
The two roles a script plays
Section titled “The two roles a script plays”Both engines, both roles use the same scope. They differ only in what they return.
- A
filterscriptis a predicate — the message is kept when the result is truthy. - A
scriptstage 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 }" }{ "filter": { "script": "return value > 40.0" } }{ "script": "return { 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.
The scope
Section titled “The scope”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), Luabody.samples[1].value(1-based), and Lua arrays iterate withipairs/ length with#.
What a script returns
Section titled “What a script returns”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/falsedrop, everything else keeps). An error is treated as drop (logged) — a filter fails closed. - Transform → the new body: a map (Rhai
#{…}) / table (Lua{…}) replacesbody; 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).
Multi-signal inputs
Section titled “Multi-signal inputs”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 /nilin Lua) otherwise. Check for an input with"x" in inputs(Rhai) orinputs.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 norequiredinput, 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
scriptlast 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}-- Availability × Performance × Quality, refreshed on any operand change.for _, k in ipairs({ "running", "idealCycleS", "plannedRunS", "totalCount", "goodCount" }) do if inputs[k] == nil then return nil end -- wait until every operand existsendif inputs.running.value ~= true then return nil end -- line stopped → hold the last value
local perf = (inputs.idealCycleS.value * inputs.totalCount.value) / inputs.plannedRunS.valuelocal qual = inputs.goodCount.value / inputs.totalCount.value
return { oee = perf * qual, performance = perf, quality = qual, basedOn = trigger.name -- which operand refreshed this result}Script state
Section titled “Script state”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.
Sandbox & limits
Section titled “Sandbox & limits”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 likeipairs/pairs/tostring) and nils outos,io,package,require,load,debug— there is noffi, 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.
A note on the languages
Section titled “A note on the languages”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.
Cookbook
Section titled “Cookbook”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) }local function to_fahrenheit(c) return c * 1.8 + 32.0 end
if #samples == 0 then return nil end -- no reading → drop
return { 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.
2. Array node: mean, peak, and RMS
Section titled “2. Array node: mean, peak, and RMS”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() }local function mean(xs) if #xs == 0 then return 0.0 end local s = 0.0 for _, x in ipairs(xs) do s = s + x end return s / #xsendlocal peak = value[1]for _, x in ipairs(value) do if x > peak then peak = x end endreturn { mean = mean(value), peak = peak, n = #value }RMS is the same shape with math.sqrt:
local sumsq = 0.0for _, x in ipairs(value) do sumsq = sumsq + x * x endreturn { rms = math.sqrt(sumsq / #value) }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 }local deltas = {}for i = 2, #samples do deltas[#deltas + 1] = samples[i].value - samples[i - 1].valueendreturn { 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() >= 2local c = 0for _, x in ipairs(value) do if x > 50 then c = c + 1 end endreturn c >= 2How & 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}return { 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" } ]}return { 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.
7. Map a status string to a code
Section titled “7. Map a status string to a code”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 }local s = body.statuslocal code = 99if s == "RUNNING" then code = 1elseif s == "IDLE" then code = 0elseif s == "FAULT" then code = -1 endreturn { 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.
8. Total an array
Section titled “8. Total an array”Goal: sum an array value.
#{ "total": value.reduce(|a, v| a + v, 0.0) }local total = 0.0for _, x in ipairs(value) do total = total + x endreturn { total = total }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}-- filter:return header.name == "SouthboundSignalUpdate"-- transform:return { 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.
Limits and gotchas
Section titled “Limits and gotchas”- 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 noffi). - Integer vs. float — a JSON
20is an integer and20.0a float; multiply by a float when you need a float result from integer inputs. - 1-based Lua arrays —
samples[1]is the first sample in Lua,samples[0]in Rhai.