What a flow is
A flow is a JSON document with three lists of steps and the settings you tune:
{
"schema": "benchmesh.flow/v1",
"name": "Hold 5 V with the load",
"description": "Raises or lowers the load current until the DMM reads 5 V.",
"params": [
{"name": "target", "value": 5.0, "unit": "V", "description": "Voltage to hold", "min": 0, "max": 30}
],
"setup": [ ...steps ],
"steps": [ ...steps ],
"cleanup": [ ...steps ],
"timeout_s": 600
}- params - the adjustable settings, shown as a form above the flow: a target voltage, a step size, a duration. Their names are variables in the steps. Values are numbers, strings or true/false;
min/maxare checked when a run starts, and a run may override values ("params": {"target": 4.8}). - setup runs first, then steps.
- cleanup always runs - after success, failure, a
stopstep and an abort. Only aborting the cleanup itself (a second abort) skips the rest of it. Switch outputs off here. - timeout_s (optional) bounds setup and steps; when it is reached the run fails and the cleanup runs.
Every instrument step names device (the instrument’s id in your configuration), class (PSU, DMM, ELL, AWG, OSC, LCR, SAL) and channel (default 1). Operations and fields are those of the class - e.g. a power supply’s voltage, current and output setters and its VOUT, IOUT, POUT, OUTPUT readings; a load’s mode, curr, volt, res, pow, input and VIN, IIN, PIN, INPUT; a multimeter’s MEAS reading. The vocabulary endpoint lists exactly what your instruments offer.
Before a flow is saved or run it is validated against your bench. Every problem carries a path such as steps[2].then[0]: unknown fields, expressions that do not compile, variables used before they are set, a device that is not configured, a class or channel it does not have, an unknown setter, query or field, a literal outside the setter’s range or allowed values, a loop without a bound. A configured instrument that is offline is a warning, not an error.
Steps
Each step is an object with do:
| do | What it does | Example |
|---|---|---|
set | Calls a setter of the class (op) with a literal value or an expr. Value limits come from the instrument’s definition. Waits until the instrument reports the value. | {"do": "set", "device": "psu-1", "class": "PSU", "op": "voltage", "value": 5} |
read | Reads a live status field (e.g. PSU VOUT, DMM MEAS, ELL VIN) or runs a query op, into the variable into. Numbers become numbers, ON / OFF and modes become strings. | {"do": "read", "device": "dmm-1", "class": "DMM", "field": "MEAS", "into": "v"} |
wait | Pauses for seconds or an expr. Abort interrupts it at once. | {"do": "wait", "seconds": 2} |
let | Assigns the result of expr to var. | {"do": "let", "var": "p", "expr": "v * i"} |
if | Runs then when condition is true, else the optional else. | {"do": "if", "condition": "v > target", "then": ["…"], "else": ["…"]} |
repeat | Loop: times, or while (tested before each pass) or until (tested after). max_iterations bounds it (default 10000). iteration counts 1, 2, … inside. | {"do": "repeat", "until": "v <= cutoff", "max_iterations": 1000, "steps": ["…"]} |
sweep | Runs steps once per value with var set: from / to (included) / step, or a values list. | {"do": "sweep", "var": "i", "from": 0, "to": 2, "step": 0.1, "steps": ["…"]} |
check | Assertion. When condition is false: fail: "stop" (default) fails the run, "warn" logs a warning and goes on. | {"do": "check", "condition": "v < 5.5", "message": "over-voltage: {v} V", "fail": "stop"} |
log | A line in the run log; {name} shows a variable. | {"do": "log", "message": "pass {iteration}: {v} V"} |
prompt | Pauses until you press Continue (in the app, or continue_run over MCP). | {"do": "prompt", "message": "Connect the battery, then press Continue"} |
record | action: "start" with name, interval_s (1-3600 s, default 1) and the numeric series to record; "stop" ends it. A recording the run started is stopped when the run ends anyway. | {"do": "record", "action": "start", "name": "Discharge", "interval_s": 1, "series": [{"device": "load-1", "class": "ELL", "field": "VIN", "label": "Battery V"}]} |
stop | Ends the main part successfully, with an optional message. The cleanup still runs. | {"do": "stop", "message": "done"} |
Expressions
expr, condition, while and until are CEL expressions - the expression language of the device definitions, which always terminates.
- Operators:
+ - * / %,== != < <= > >=,&& || !,cond ? a : b. - Functions:
min(a, b),max(a, b),abs(x),round(x, digits),num(x)(text to number), plus CEL’s built-ins. - All numbers are decimals:
target + 1works whentargetis 4.5. - Variables: params, the
intoof aread, thevarof aletorsweep,iterationinside loops andelapsed(seconds since the run started). A variable must be set before a step uses it. - Messages of
log,check,promptandstopshow variables with{name}- not expressions; compute withletfirst.
Safety
- Arming. A
setthat can switch an output or input on - a setter markedenergizesin the definition, with a value that is not its safe value (e.g. PSUoutputor ELLinput= ON) - makes the run need to be armed. The app lists those steps and asks once; over the API, a run without"arm": trueis refused with that list. - Every set is confirmed. After sending, the run waits up to 6 s until the instrument reports the value - the operation’s query if it has one, else the status field of the same name (
output→OUTPUT). Numbers match within half a unit of the setter’s last decimal. Not confirmed → the step fails. A setter with nothing to read back is logged as “sent (no read-back)”. - Fresh reads. A
readof a status field waits (up to 5 s) for a status update newer than the run’s lastset, so a reading always reflects the change before it. Stale or offline → the step fails. - Abort any time: the red button in the top bar while a run is active,
Escapein the run view,POST /automation/run/abortor MCPabort_run. Waits and prompts are interrupted immediately; an instrument exchange in progress finishes first. The cleanup still runs. - Safe state. When a run fails or is aborted, after the cleanup every energising operation the run switched on is set to its safe value (OFF). A successful run leaves the instruments as its cleanup left them.
- One run at a time. Starting a second run is refused.
- Bounded loops. Every
repeatstops atmax_iterations(default 10000, with a warning in the log); asweephas at most 10000 points. Each loop pass takes at least 0.1 s, so a loop never hammers an instrument.
Running and the log
A run goes through the states running, waiting (at a prompt), cleanup and ends finished, failed or aborted (idle before the first run). Every step start and result, every value set and read, every warning and error is a log event {seq, t, level, path, msg} - level info, warn or error, the step’s path, and t in milliseconds of wall-clock time. That is the recordings’ time domain: a run’s log lines up with the series it recorded. The last 5000 events are kept.
Flows are saved as files in your BenchMesh folder (~/.benchmesh/flows/<id>.json).
Worked examples
Copy one into the flow editor and replace psu-1, dmm-1 and load-1 with the ids of your instruments; validation tells you if one of them lacks an operation, field or channel. Start with low limits and watch the first run.
a) Hold a voltage by adjusting the electronic load
Read the DMM; if the voltage is above the target raise the load current, else lower it - in small steps, never above a current limit.
{
"schema": "benchmesh.flow/v1",
"name": "Hold 5 V with the load",
"description": "Raises or lowers the load current until the DMM reads the target voltage.",
"params": [
{"name": "target", "value": 5, "unit": "V", "description": "Voltage to hold", "min": 0, "max": 30},
{"name": "step", "value": 0.05, "unit": "A", "description": "Current change per pass", "min": 0.001, "max": 0.5},
{"name": "limit", "value": 2, "unit": "A", "description": "Highest load current", "min": 0, "max": 10},
{"name": "vmax", "value": 6, "unit": "V", "description": "Stop above this voltage"}
],
"setup": [
{"do": "set", "device": "load-1", "class": "ELL", "op": "curr", "value": 0},
{"do": "set", "device": "load-1", "class": "ELL", "op": "input", "value": "ON"}
],
"steps": [
{"do": "repeat", "times": 300, "steps": [
{"do": "read", "device": "dmm-1", "class": "DMM", "field": "MEAS", "into": "v"},
{"do": "check", "condition": "v < vmax", "message": "over-voltage: {v} V", "fail": "stop"},
{"do": "read", "device": "load-1", "class": "ELL", "field": "IIN", "into": "i"},
{"do": "if", "condition": "v > target", "then": [
{"do": "set", "device": "load-1", "class": "ELL", "op": "curr", "expr": "min(i + step, limit)"}
], "else": [
{"do": "set", "device": "load-1", "class": "ELL", "op": "curr", "expr": "max(i - step, 0)"}
]},
{"do": "log", "message": "pass {iteration}: {v} V at {i} A"},
{"do": "wait", "seconds": 1}
]}
],
"cleanup": [
{"do": "set", "device": "load-1", "class": "ELL", "op": "input", "value": "OFF"}
]
}- The load current only moves between 0 A and
limit:min(…)andmax(…)keep every new setpoint inside the bounds. - The
checkstops the run (and the cleanup switches the load off) if the voltage ever exceedsvmax. - Each pass reads fresh values: a
readafter asetwaits for a status update newer than that set.
b) Battery discharge at constant current
Discharge a cell on the electronic load at a constant current until it reaches the cut-off voltage, record voltage and current, and report the capacity.
{
"schema": "benchmesh.flow/v1",
"name": "Battery discharge",
"description": "Constant-current discharge to a cut-off voltage, recorded.",
"params": [
{"name": "current", "value": 0.5, "unit": "A", "description": "Discharge current", "min": 0.01, "max": 5},
{"name": "cutoff", "value": 3, "unit": "V", "description": "Stop at this voltage", "min": 0.5, "max": 60},
{"name": "interval", "value": 5, "unit": "s", "description": "Time between readings", "min": 1, "max": 60}
],
"setup": [
{"do": "prompt", "message": "Connect the battery to the load, then press Continue"},
{"do": "set", "device": "load-1", "class": "ELL", "op": "mode", "value": "CURR"},
{"do": "set", "device": "load-1", "class": "ELL", "op": "curr", "expr": "current"},
{"do": "record", "action": "start", "name": "Battery discharge", "interval_s": 1, "series": [{"device": "load-1", "class": "ELL", "field": "VIN", "label": "Battery V"}, {"device": "load-1", "class": "ELL", "field": "IIN", "label": "Battery I"}]},
{"do": "set", "device": "load-1", "class": "ELL", "op": "input", "value": "ON"}
],
"steps": [
{"do": "repeat", "until": "v <= cutoff", "max_iterations": 10000, "steps": [
{"do": "wait", "expr": "interval"},
{"do": "read", "device": "load-1", "class": "ELL", "field": "VIN", "into": "v"},
{"do": "read", "device": "load-1", "class": "ELL", "field": "IIN", "into": "i"},
{"do": "check", "condition": "abs(i - current) < 0.05", "message": "load current {i} A differs from the setpoint", "fail": "warn"}
]},
{"do": "check", "condition": "v <= cutoff", "message": "stopped before the cut-off: {v} V", "fail": "stop"},
{"do": "let", "var": "mah", "expr": "round(current * elapsed / 3.6, 0)"},
{"do": "log", "message": "cut-off reached: {v} V, about {mah} mAh"},
{"do": "record", "action": "stop"}
],
"cleanup": [
{"do": "set", "device": "load-1", "class": "ELL", "op": "input", "value": "OFF"}
],
"timeout_s": 43200
}- The
promptpauses the run until you press Continue - connect the battery first. - The loop ends when
v <= cutoff(tested after each pass).timeout_sbounds the whole run to 12 h; if it is reached the run fails and the load input is switched off. - Capacity is current × time:
current * elapsed / 3.6gives mAh (A·s ÷ 3.6). The recording lines up with the run log, so you can also compute it from the CSV.
c) Characterise a DC-DC converter (input-voltage sweep)
The supply feeds the converter, the load draws a fixed current from its output. Sweep the input voltage and log the efficiency, output power ÷ input power, at each point.
{
"schema": "benchmesh.flow/v1",
"name": "Converter efficiency vs input voltage",
"description": "Sweeps the supply voltage and logs converter efficiency at a fixed load current.",
"params": [
{"name": "load_current", "value": 1, "unit": "A", "description": "Converter output current", "min": 0, "max": 5},
{"name": "ilimit", "value": 2, "unit": "A", "description": "Supply current limit", "min": 0.1, "max": 5},
{"name": "settle", "value": 2, "unit": "s", "description": "Wait after each step", "min": 0.5, "max": 30}
],
"setup": [
{"do": "set", "device": "psu-1", "class": "PSU", "op": "voltage", "value": 8},
{"do": "set", "device": "psu-1", "class": "PSU", "op": "current", "expr": "ilimit"},
{"do": "set", "device": "load-1", "class": "ELL", "op": "mode", "value": "CURR"},
{"do": "set", "device": "load-1", "class": "ELL", "op": "curr", "value": 0},
{"do": "set", "device": "psu-1", "class": "PSU", "op": "output", "value": "ON"},
{"do": "wait", "seconds": 1},
{"do": "set", "device": "load-1", "class": "ELL", "op": "curr", "expr": "load_current"},
{"do": "set", "device": "load-1", "class": "ELL", "op": "input", "value": "ON"}
],
"steps": [
{"do": "sweep", "var": "vin", "from": 8, "to": 24, "step": 2, "steps": [
{"do": "set", "device": "psu-1", "class": "PSU", "op": "voltage", "expr": "vin"},
{"do": "wait", "expr": "settle"},
{"do": "read", "device": "psu-1", "class": "PSU", "field": "POUT", "into": "pout"},
{"do": "read", "device": "load-1", "class": "ELL", "field": "PIN", "into": "pin"},
{"do": "let", "var": "eff", "expr": "pout > 0 ? round(100 * pin / pout, 1) : 0"},
{"do": "log", "message": "{vin} V in: {pout} W → {pin} W, efficiency {eff} %"}
]}
],
"cleanup": [
{"do": "set", "device": "load-1", "class": "ELL", "op": "input", "value": "OFF"},
{"do": "set", "device": "psu-1", "class": "PSU", "op": "output", "value": "OFF"}
]
}sweepsetsvinto 8, 10, … 24 (the end value is included) and runs its steps once for each.- Each point waits
settleseconds, then reads the supply’s output power (POUT, the converter’s input) and the load’s input power (PIN, the converter’s output). - A log line can only show variables, so the efficiency is computed with
letfirst. The guardpout > 0avoids a division by zero.
d) Power-up sequencing of two supply channels
Bring up a 3.3 V rail, check it, then a 5 V rail after a delay; hold until you continue, and power down in reverse order.
{
"schema": "benchmesh.flow/v1",
"name": "Power-up sequence 3.3 V → 5 V",
"description": "Sequenced power-up of two rails with checks, reverse power-down.",
"params": [
{"name": "v1", "value": 3.3, "unit": "V", "description": "Rail 1 (channel 1)", "min": 0, "max": 30},
{"name": "v2", "value": 5, "unit": "V", "description": "Rail 2 (channel 2)", "min": 0, "max": 30},
{"name": "i1", "value": 0.5, "unit": "A", "description": "Current limit, channel 1", "min": 0, "max": 3},
{"name": "i2", "value": 0.5, "unit": "A", "description": "Current limit, channel 2", "min": 0, "max": 3},
{"name": "delay", "value": 0.5, "unit": "s", "description": "Delay between the rails", "min": 0, "max": 10}
],
"setup": [
{"do": "set", "device": "psu-1", "class": "PSU", "channel": 1, "op": "voltage", "expr": "v1"},
{"do": "set", "device": "psu-1", "class": "PSU", "channel": 1, "op": "current", "expr": "i1"},
{"do": "set", "device": "psu-1", "class": "PSU", "channel": 2, "op": "voltage", "expr": "v2"},
{"do": "set", "device": "psu-1", "class": "PSU", "channel": 2, "op": "current", "expr": "i2"},
{"do": "prompt", "message": "Connect the board: channel 1 to the 3.3 V rail, channel 2 to the 5 V rail. Press Continue to power up."}
],
"steps": [
{"do": "set", "device": "psu-1", "class": "PSU", "channel": 1, "op": "output", "value": "ON"},
{"do": "wait", "expr": "delay"},
{"do": "read", "device": "psu-1", "class": "PSU", "channel": 1, "field": "VOUT", "into": "out1"},
{"do": "check", "condition": "out1 > v1 * 0.95", "message": "rail 1 did not come up: {out1} V", "fail": "stop"},
{"do": "set", "device": "psu-1", "class": "PSU", "channel": 2, "op": "output", "value": "ON"},
{"do": "wait", "expr": "delay"},
{"do": "read", "device": "psu-1", "class": "PSU", "channel": 2, "field": "VOUT", "into": "out2"},
{"do": "check", "condition": "out2 > v2 * 0.95", "message": "rail 2 did not come up: {out2} V", "fail": "stop"},
{"do": "read", "device": "psu-1", "class": "PSU", "channel": 1, "field": "IOUT", "into": "cur1"},
{"do": "read", "device": "psu-1", "class": "PSU", "channel": 2, "field": "IOUT", "into": "cur2"},
{"do": "log", "message": "both rails up: {out1} V / {cur1} A, {out2} V / {cur2} A"},
{"do": "prompt", "message": "Board powered. Press Continue to power down."}
],
"cleanup": [
{"do": "set", "device": "psu-1", "class": "PSU", "channel": 2, "op": "output", "value": "OFF"},
{"do": "wait", "expr": "delay"},
{"do": "set", "device": "psu-1", "class": "PSU", "channel": 1, "op": "output", "value": "OFF"}
]
}- Setpoints and current limits are set before any output is switched on.
- Each rail is checked after it comes up; a rail below 95 % of its setpoint stops the run, and the cleanup switches both off - channel 2 first.
- This needs a supply with an output switch per channel. If yours switches all channels together, validation will tell you.
e) Over-current watchdog
Watch a supply that is already on: read the output current every second for a set time and stop with a failure - switching the output off - if it reaches the limit.
{
"schema": "benchmesh.flow/v1",
"name": "Over-current watchdog",
"description": "Switches the supply off if its output current reaches the limit.",
"params": [
{"name": "limit", "value": 1.5, "unit": "A", "description": "Current limit", "min": 0, "max": 10},
{"name": "duration", "value": 600, "unit": "s", "description": "How long to watch", "min": 1, "max": 86400}
],
"setup": [
{"do": "let", "var": "over", "expr": "false"}
],
"steps": [
{"do": "repeat", "until": "elapsed > duration", "max_iterations": 100000, "steps": [
{"do": "read", "device": "psu-1", "class": "PSU", "field": "IOUT", "into": "i"},
{"do": "let", "var": "over", "expr": "i >= limit"},
{"do": "check", "condition": "i < limit", "message": "over-current: {i} A (limit {limit} A)", "fail": "stop"},
{"do": "wait", "seconds": 1}
]},
{"do": "log", "message": "no over-current in {duration} s"}
],
"cleanup": [
{"do": "if", "condition": "over", "then": [
{"do": "set", "device": "psu-1", "class": "PSU", "op": "output", "value": "OFF"}
]}
]
}- The loop runs until
elapsed(seconds since the run started) passesduration;max_iterationsis a second bound. - This flow never switches anything on, so it needs no arming. Because the run did not switch the output on, the automatic safe state would not touch it - the cleanup switches it off itself, but only when the limit was reached (
over).
REST API
Everything the Automation view does goes through the app’s local API at http://127.0.0.1:57666 - scripts, Node-RED and CI can save, validate and run flows too.
| Method | Path | Body / result |
|---|---|---|
| GET | /automation/vocabulary | What flows can address: per configured device id, name, online, and per class channels, fields (status keys with type and description) and operations (get, set, the value spec, energizes). |
| GET | /automation/flows | Saved flows: [{id, name, description, updated_at}] |
| POST | /automation/flows | Save a flow → 201 {id, flow, issues} (saved with warnings); errors → 422 {detail, issues} |
| GET · PUT · DELETE | /automation/flows/{id} | The stored flow / replace it / delete it (204) |
| POST | /automation/validate | Check a flow → {ok, issues: [{path, level, message}], energizes: [{path, device, class, channel, op}]} |
| POST | /automation/run | {flow_id} or {flow}, optional params and arm → 202 run status. 409 when a run is active or the flow needs arm (with the energizes list); 422 when invalid or a parameter is outside its min / max. |
| GET | /automation/run?after=<seq> | The current or last run: id, flow, state, started_at, ended_at, path, vars, error, prompt, recording_id, last_seq, events (only events after after) |
| POST | /automation/run/abort | Abort → 204 (409 when nothing runs). The cleanup still runs; a second abort stops the cleanup. |
| POST | /automation/run/continue | Continue a run waiting at a prompt → 204 (409 when nothing waits) |
| GET · PUT | /automation/assistant | Flow assistant settings - see the assistant |
| GET | /automation/assistant/models | The provider’s models [{id, name}] |
| POST | /automation/assist | {instruction, flow?} → {flow, explanation, issues, attempts} |
| GET · PUT | /automation/mcp | MCP access {access: "off" | "read" | "control"} and how to connect - see MCP |
curl -X POST http://127.0.0.1:57666/automation/run \
-H 'content-type: application/json' \
-d '{"flow_id": "hold-5-v-with-the-load", "params": {"target": 4.8}, "arm": true}'
curl 'http://127.0.0.1:57666/automation/run?after=0'A flow that switches something on, started without arm:
HTTP/1.1 409 Conflict
{
"detail": "…",
"energizes": [{"path": "setup[1]", "device": "load-1", "class": "ELL", "channel": 1, "op": "input"}]
}The run status, polled with after set to the last seq you have seen:
{
"id": 3, "state": "finished", "path": "", "error": null, "recording_id": 12, "last_seq": 41,
"vars": {"target": 5, "v": 4.998, "i": 0.85},
"events": [
{"seq": 1, "t": 1792440123456, "level": "info", "path": "", "msg": "run of \"Hold 5 V with the load\" started"},
{"seq": 2, "t": 1792440123701, "level": "info", "path": "setup[0]", "msg": "load-1 ELL curr = 0.000"},
{"seq": 5, "t": 1792440124950, "level": "info", "path": "steps[0].steps[0]", "msg": "v = 5.012 (dmm-1 MEAS)"}
]
}