Skip to content
BenchMesh

Flows · benchmesh.flow/v1

Automation with flows

A flow reads instruments, decides, sets, waits, loops, sweeps and records - read the DMM; if the voltage is above the target raise the load current, else lower it; stop at 2 A. You build it in the app’s Automation view, have the assistant assemble it, or let an AI client write it. All three produce the same document, checked by the same validator and run by the same executor.

What a flow is

A flow is a JSON document with three lists of steps and the settings you tune:

Flow document
{
  "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 / max are 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 stop step 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:

doWhat it doesExample
setCalls 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}
readReads 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"}
waitPauses for seconds or an expr. Abort interrupts it at once.{"do": "wait", "seconds": 2}
letAssigns the result of expr to var.{"do": "let", "var": "p", "expr": "v * i"}
ifRuns then when condition is true, else the optional else.{"do": "if", "condition": "v > target", "then": ["…"], "else": ["…"]}
repeatLoop: 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": ["…"]}
sweepRuns 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": ["…"]}
checkAssertion. 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"}
logA line in the run log; {name} shows a variable.{"do": "log", "message": "pass {iteration}: {v} V"}
promptPauses until you press Continue (in the app, or continue_run over MCP).{"do": "prompt", "message": "Connect the battery, then press Continue"}
recordaction: "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"}]}
stopEnds 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 + 1 works when target is 4.5.
  • Variables: params, the into of a read, the var of a let or sweep, iteration inside loops and elapsed (seconds since the run started). A variable must be set before a step uses it.
  • Messages of log, check, prompt and stop show variables with {name} - not expressions; compute with let first.

Safety

  • Arming. A set that can switch an output or input on - a setter marked energizes in the definition, with a value that is not its safe value (e.g. PSU output or ELL input = ON) - makes the run need to be armed. The app lists those steps and asks once; over the API, a run without "arm": true is 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 read of a status field waits (up to 5 s) for a status update newer than the run’s last set, 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, Escape in the run view, POST /automation/run/abort or MCP abort_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 repeat stops at max_iterations (default 10000, with a warning in the log); a sweep has 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.

hold-voltage.json
{
  "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(…) and max(…) keep every new setpoint inside the bounds.
  • The check stops the run (and the cleanup switches the load off) if the voltage ever exceeds vmax.
  • Each pass reads fresh values: a read after a set waits 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.

battery-discharge.json
{
  "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 prompt pauses the run until you press Continue - connect the battery first.
  • The loop ends when v <= cutoff (tested after each pass). timeout_s bounds 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.6 gives 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.

converter-efficiency.json
{
  "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"}
  ]
}
  • sweep sets vin to 8, 10, … 24 (the end value is included) and runs its steps once for each.
  • Each point waits settle seconds, 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 let first. The guard pout > 0 avoids 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.

power-sequence.json
{
  "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.

current-watchdog.json
{
  "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) passes duration; max_iterations is 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.

MethodPathBody / result
GET/automation/vocabularyWhat 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/flowsSaved flows: [{id, name, description, updated_at}]
POST/automation/flowsSave 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/validateCheck 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/abortAbort → 204 (409 when nothing runs). The cleanup still runs; a second abort stops the cleanup.
POST/automation/run/continueContinue a run waiting at a prompt → 204 (409 when nothing waits)
GET · PUT/automation/assistantFlow assistant settings - see the assistant
GET/automation/assistant/modelsThe provider’s models [{id, name}]
POST/automation/assist{instruction, flow?} → {flow, explanation, issues, attempts}
GET · PUT/automation/mcpMCP access {access: "off" | "read" | "control"} and how to connect - see MCP
Run a saved flow, then follow it
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:

409 - needs arming
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:

GET /automation/run
{
  "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)"}
  ]
}