Ktest User Manual

Truke Ktest · bus, bench and recording on one timeline · prospective manual

Ktest user manual

Ktest turns a Linux machine and the test equipment wired to it into an instrument rack you use from a browser, a script or an LLM agent.

It monitors and transmits on CAN, drives bench supplies and oscilloscopes, and records all of it, with every command anyone gave, into one open file against one clock.

Ktest is not finished. This manual describes it as it is meant to be, and marks what is not there yet.

One voltage setpoint as three accounts over time. 12 V is asked for; the supply, rated 6 V, holds 6 V; 5 V is measured while the current limit is active, and 6 V once the limit is raised. 12 V 6 0 12 V asked current limit raised
Every controlled quantity is recorded as three accounts. Here 12 V is asked of a channel rated 6 V: the supply holds 6 V, and 5 V is measured until the current limit is raised. One channel for all three would hide both events.
  • built works today, as described
  • planned decided, not yet written
  • proposed an idea put up for discussion
Contents
Conventions of this manual
  • $ ktestd … is a command typed in a terminal on the Linux machine that runs Ktest.
  • Anything not marked is built. A mark on a heading covers its whole section.
  • Names such as psu1, scope1 and can0 are this manual's bench. Yours are whatever you name on the command line.
  • Durations are written as Go writes them: 10ms, 30s, 1m0s. Voltages are in V, currents in A, times on a scope in s.
  • The examples (examples/ in the repository) run without hardware and are referred to throughout.

1 Using Ktest

1.1 What Ktest is

Ktest is one program, ktestd, running on a Linux machine that is wired to the bench: a CAN interface, and instruments reached over the network. It owns that hardware, records everything it sees and does into one file, and serves an instrument rack to any browser. Three kinds of work that today need three unrelated tools meet in it:

WorkWhat Ktest doesStatus
Busmonitors CAN and CAN FD, decodes with a DBC, sends single frames and kernel-paced cyclic ones, by bytes or by signalbuilt
LIN, ISO-TP, J1939planned
Benchdrives programmable supplies and oscilloscopes over SCPIbuilt against simulators
loads, multimeters, function generators; USBTMCplanned
Recordingputs all of it on one timeline, in one open format, Logbbuilt

The third row is the reason Ktest exists. Bus tools do not see the bench and bench software does not see the bus, so relating a supply transient to the CAN traffic it caused is done by hand, if at all. In a Ktest recording both are in the same file against the same clock.

Architecture. A browser, a script and an LLM agent each send commands to ktestd as HTTP requests and receive results as a Logb stream. Inside ktestd the control plane drives the bus layer and the instrument layer, and a recorder writes everything to a Logb file. The bus layer connects to the CAN interface and the instrument layer to supplies and scopes. Browserthe rack, on any OS commandsREST resultsLogb stream Scriptcurl, Python, CI commandsREST resultsLogb stream LLM agentthrough tools commandsREST resultsLogb stream ktestdLinux, one static binary Control planeleases, arming, limits Bus layerSocketCAN Instrument layerSCPI over TCP Recorderevery frame, value and decision CAN interfacecan0 Instrumentssupplies, oscilloscopes run.logbthe recording
Commands go in as HTTP requests. Results come back only on the stream, which carries the same bytes the recorder writes to the file.

There are three ways to use it, and they are the same interface seen from three clients:

  • The rack, in a browser: panels of knobs, gauges, plots and scope traces laid out per instrument (§1.4).
  • A script: commands are plain HTTP requests, so curl, Python or a CI job drives the bench (§1.5).
  • An LLM agent: the same requests, offered to a model as tools, under the same limits as everyone else (§1.6).

All three are bound by one rule: a command is answered with accepted or refused, never with state. What the command did arrives on the stream, as a record, for every client at once. That is why two browsers stay in step, a reloaded page recovers the rack, and a script and a person can watch the same bench without disagreeing about it. Part 3 explains the design.

1.2 Build and install

Ktest is written in Go and has no C dependencies. The rack's browser code is committed already built and is embedded in the binary, so Node is not needed to build or run it.

From source today

You need Linux, Go 1.25 or later, and the Logb repository checked out beside Ktest's, because Logb has no tagged release yet and go.mod points at ../logb:

$ git clone https://github.com/rveen/logb
$ git clone https://github.com/rveen/ktest
$ cd ktest
$ go build -o bin/ ./cmd/...
$ ls bin
canlogb  ktestd  logbcheck  psusim  scopesim  vcanprobe

For a small ARM board, cross-compile on any machine and copy the binary over:

$ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/ktestd ./cmd/vcanprobe

To test bus work without hardware, install can-utils (candump, canplayer, cangen) and make a virtual bus:

$ sudo modprobe vcan
$ sudo ip link add dev vcan0 type vcan
$ sudo ip link set up vcan0

Rebuilding the rack

Only after changing something under web/src/:

$ cd web && npm install && npm run build      # writes web/dist/, which ktestd embeds
$ npm test                                   # the browser's Logb reader against Go's

As a service planned

The intended installation is a release binary and a systemd unit running as an ordinary user, with its configuration in one directory. Nothing of this is packaged yet. The layout it is heading for:

PathWhat it holds
/usr/bin/ktestdthe daemon
/etc/ktest/limits.jsonthe limits no client can loosen (§2.4)
/etc/ktest/tokenswho may log in, as hashes (§2.5)
/etc/ktest/panels/*.jsonrack panels (§2.6)
/run/ktest.sockthe local control socket, mode 0660
~/.local/state/ktestd/what the daemon makes for itself, such as its TLS certificate
/var/lib/ktest/*.logbrecordings

A Ktest Box, a small appliance with the daemon, a CAN interface and a tested configuration already on it, is the commercial form of the same thing. The software on it is the open-source one.

The machine

Any Linux machine with a SocketCAN interface works. The bench this manual is written around is an Orange Pi Zero 2 with an MCP2518FD CAN FD controller on its SPI header, under the mainline mcp251xfd driver; examples/orangepi-zero2-mcp2518fd/ has the wiring and the device tree overlay. USB adapters that speak gs_usb (CANable and its derivatives) work wherever a plain interface is enough. Vector VN hardware is not supported.

Ktest opens an interface and never configures it. Set the bit rates yourself, which is the one step that needs root:

$ sudo ip link set can0 up type can bitrate 500000 dbitrate 2000000 fd on
$ ip -details link show can0 | grep -E 'bitrate|dbitrate'    # the rates it actually runs

Then run vcanprobe -i can0 once. It measures what this kernel and adapter can do (loopback, CAN FD, error frames, timestamps, cyclic pacing, drop reporting), and Ktest's timing claims hold only where the probe says so.

1.3 A first bench

examples/bench runs a whole bench with no hardware: a simulated two-channel supply, a simulated Siglent SDS1202X-E oscilloscope, a virtual CAN bus, and ktestd recording all three and serving the rack.

$ cd examples/bench
$ ./demo.sh
  ...
  Open https://localhost:8445/ in Chrome and log in with this token:

    3f9c…

The script builds the programs, makes a login token the first time, and runs until Ctrl-C, recording to demo.logb. The certificate is self-signed, so the browser warns once. Paste the token.

Behind the script is one command line, and it is the whole configuration of a bench:

$ ktestd -https :8445 -tokens tokens \
         -panel rack.json -limits limits.json \
         -psu psu1=127.0.0.1:5025,2 \
         -scope scope1=127.0.0.1:5026,2,full=ch1 \
         -o demo.logb vcan0

Things to try, in order:

  1. Press Take control, then Arm. The supply's widgets unlock. The scope's traces were already drawing, because reading an input needs no permission.
  2. Raise ch1 current limit to 0.5 A, set ch1 voltage to 5 V, and switch ch1 output on. The readouts show 5 V and 0.5 A, measured at the simulated 10 Ω load.
  3. Lower the current limit to 0.2 A. The supply goes into constant current and the measured voltage falls to 2 V, although the setpoint still says 5 V.
  4. Ask for 20 V. The request is refused, because limits.json caps the channel at 12 V. The refusal is in the recording.
  5. Set C1 volts/div to 0.3. The scope has only 1-2-5 steps, so it holds 0.2, and the widget shows both what you asked for and what is held.
  6. Press STOP. Every instrument is disarmed and the supply's outputs go off. The scope is left as it is.

Afterwards, check the recording against the simulators' own transcripts, which were written by something that knows nothing of Logb:

$ bin/logbcheck -control -psu psu1=psu.log -scope scope1=scope.log demo.logb /dev/null

examples/supply-bench and examples/scope-bench are the same with one instrument each, and show more of each one's widgets.

1.4 The rack, in a browser

The rack is served by the daemon it drives, at the address given with -https. It works in any current browser on any operating system; nothing is installed on the client.

Logging in

The page asks for a token (§2.5). A token has a name and a role. view may watch and may stop; operate may also take control, arm, write and save panels.

The control bar

ControlWhat it does
Take controltakes the lease of every instrument on the panel in one press. A lease is single-writer: while you hold it nobody else can write to that instrument. The page renews it; if the page dies, the lease runs out by itself after its term (30 s).
Armarms the instruments you hold. Nothing is sent or set on an instrument that is not armed.
STOPdisarms everything the daemon drives, stops all cyclic transmit and switches every supply output off. It needs no lease and works for a view name, because a stop that needed the lease would be out of reach exactly when its holder has hung.
Edit panelturns the rack into its own editor (§2.7).

Each instrument has a box, and the box's header says what is true of that instrument: its kind, who holds its lease, and whether it is armed. When the instruments disagree, one held by someone else or one armed and another not, the bar says how many instead of rounding it off.

The widgets

A widget that writes unlocks only when this page holds the lease and the recording says the instrument is armed. Every writing widget shows two things: the value you asked for and the value the instrument holds. They differ when an instrument clamps a setpoint to its rating or rounds it to a step it has, and the widget marks the difference instead of hiding it.

WidgetShows or setsNotes
readoutone value, as a number with its unit
gaugeone value on a scale
lampone on/off or state value
plotseveral channels against time, scrollingchannels from different sources share one axis
scopeone oscilloscope channel, per acquisitiondrawn as a min/max band, so a one-sample spike is still visible; the trigger is the dashed line
setpointa number, typed
knob, slidera number, dragged or stepped with the arrow keyssends on release, so a drag is one decision in the recording
enumone of a list of named valuesshows no choice when the held value is not on the list
toggleon or off
buttonon while heldreleases when the pointer leaves, the window loses focus or the tab is hidden
trace tablea bus's frames as they arrive, decodedplanned

Without a panel, the rack lists every channel the stream carries, which is a quick way to see what a new bench offers.

Replay planned

A recording carries the panel that was on screen while it was made, so opening a recording brings back the rack and plays the data through the same widgets. In replay nothing actuates: hardware bindings resolve only in live mode, and the rack will show which mode it is in where it cannot be missed. Until this is built, recordings are viewed with Logb's own viewer, logbview, which also follows a file that is still being written (logbview -follow).

1.5 Driving it from scripts

Everything the rack does, it does through HTTP requests that a script can make as well. There are two ways in:

ListenerFlagWho may use it
Unix socket-sock /run/ktest.sockwhoever the file's owner and group admit; full control, no token
HTTPS-https :8443 -tokens filenamed tokens with a role; control and streams
HTTP-http :8080anyone; the streams only, read-only, no control

The sequence

Every script that writes goes through the same four steps: take the instrument's lease, arm it, do the work, release. Locally, with curl:

$ K="curl -s --unix-socket /run/ktest.sock"
$ $K -d '{"holder":"thermal sweep","ttl":"60s"}' http://ktest/psu/psu1/lease
{"seq":1,"token":"9b1e…","ttl":"1m0s"}
$ L="Ktest-Lease: 9b1e…"
$ $K -H "$L" -X POST http://ktest/psu/psu1/arm
$ $K -H "$L" -X PUT -d '{"value":0.5}'  http://ktest/channels/psu1.ch1.i.set
$ $K -H "$L" -X PUT -d '{"value":5}'    http://ktest/channels/psu1.ch1.v.set
$ $K -H "$L" -X PUT -d '{"value":true}' http://ktest/channels/psu1.ch1.out.set
{"seq":5}
$ $K -H "$L" -X PUT -d '{"ttl":"60s"}'  http://ktest/psu/psu1/lease     # renew before the term ends
$ $K -H "$L" -X DELETE http://ktest/psu/psu1/lease

The answer to a write is a sequence number. The same number is on the record of that decision in the instrument's control stream, so a script can find what became of request 5 in the recording.

If the script crashes, its lease runs out, and the instrument is disarmed: cyclic transmit stops, while a supply's outputs stay as they were, since cutting what a supply feeds is itself an act nobody asked for. Releasing the lease does the same. Use POST /disarm when the outputs must go off.

The bus

$ $K -d '{"holder":"bench script"}' http://ktest/bus/can0/lease
$ $K -H "$L" -X POST http://ktest/bus/can0/arm

# one frame, by identifier and bytes
$ $K -H "$L" -d '{"id":"0x123","data":"DEADBEEF"}' http://ktest/bus/can0/send

# one frame, by message and signals in physical units (needs -dbc)
$ $K -H "$L" -d '{"message":"EngineData","signals":{"EngineSpeed":3000,"Gear":"Reverse"}}' \
     http://ktest/bus/can0/send

# a cyclic frame every 10 ms, paced by the kernel; PUT again to change its data
$ $K -H "$L" -X PUT -d '{"data":"0102","period":"10ms"}' http://ktest/bus/can0/cyclic/123
$ $K -H "$L" -X DELETE http://ktest/bus/can0/cyclic/123

A signal left out takes the database's start value. A value is rounded to the nearest step the signal can represent and refused outside the range the database declares. Cyclic frames are sent by the kernel (CAN_BCM), not by the daemon's loop, so their timing does not depend on how busy the daemon or the script is.

Reading results

Results are not returned by requests. They are in the stream, which is an endless HTTP response in Logb format:

$ curl -N --cacert tls-cert.pem -H 'Authorization: Bearer 3f9c…' \
       https://rack:8443/stream/live > run.logb

That file is a valid recording from the moment the request starts: it opens with the schemas and the current value of every held channel, so a consumer that joins late knows that the supply was set to 12 V forty minutes ago. Read it with Logb's tools (logbdump, logbview) or its Go reader. /stream/rack is the same records uncompressed, with waveforms reduced to envelopes, which is what the browser reads.

A reader for Python, so that a notebook can take values off the live stream as arrays, is planned, as part of Logb and not of Ktest.

From Python

import requests, time

RACK = "https://rack:8443"
s = requests.Session()
s.verify = "tls-cert.pem"                      # the daemon's own certificate
s.headers["Authorization"] = "Bearer " + open("token").read().strip()

lease = s.post(f"{RACK}/psu/psu1/lease", json={"ttl": "60s"}).json()
s.headers["Ktest-Lease"] = lease["token"]
s.post(f"{RACK}/psu/psu1/arm").raise_for_status()
try:
    s.put(f"{RACK}/channels/psu1.ch1.i.set",   json={"value": 0.5})
    s.put(f"{RACK}/channels/psu1.ch1.out.set", json={"value": True})
    for mv in range(0, 12001, 500):            # 0 to 12 V in 0.5 V steps
        r = s.put(f"{RACK}/channels/psu1.ch1.v.set", json={"value": mv / 1000})
        if not r.ok:
            print(r.status_code, r.json()["error"])
        time.sleep(1)
finally:
    s.post(f"{RACK}/disarm")                   # outputs off, whatever happened
    s.delete(f"{RACK}/psu/psu1/lease")

Over HTTPS the lease holder is the token's name and cannot be chosen, so the recording's word for who held the supply is one nobody could have typed in for someone else.

Scripts inside the daemon planned

A script run by a client stops when the client does. For sequences that must survive a disconnect, such as an overnight thermal run, the daemon will run Lua scripts itself, one per run, each under a lease like any other client. Limit conditions will become expressions (v_out > 4.75 && v_out < 5.25) instead of fixed numbers. Neither will sit in the timing path: a script that stalls must not be able to stall the bus.

1.6 With an LLM agent

proposed Nothing in this section is built, and the names in it are placeholders. It describes how an agent is meant to fit, so that the design can be discussed before it is written.

An agent is a script that decides its next step by itself. Ktest already has what such a client needs: commands are small, typed HTTP requests; every refusal comes back as a sentence saying what was wrong and what to do; and the limits are enforced by the daemon, not by the client's good behaviour. So an agent gets no interface of its own. It gets the REST interface, wrapped as tools.

What works today

An agent that has a shell, such as Claude Code, can drive a bench with curl exactly as §1.5 shows. Give it a token of its own and the address, and tell it the channel names.

The tool server

ktest-mcp would be a small separate program that speaks the Model Context Protocol on one side and Ktest's REST and stream on the other. It holds the token, so the model never sees it.

$ ktestd -new-token agent:operate 2>&1 >>tokens | tail -n 1 > agent.token
$ claude mcp add ktest -- ktest-mcp -url https://rack:8443 \
      -token-file agent.token -cacert tls-cert.pem
ToolWhat it doesRole needed
benchlists the instruments, their kind, who holds each and whether it is armed, and the limits in forceview
channelslists channels with unit and current value; for a settable one, the set and the applied accountview
readreturns a channel over a time window, reduced to at most a few hundred points with min and max keptview
waveformreturns the latest acquisition of a scope channel as an envelope, with its settings and simple measurements (peak to peak, mean, frequency)view
take_control, releasetakes or gives back an instrument's lease; the server renews it while the session livesoperate
armarms an instrument the agent holdsoperate
setwrites a settable channel, then waits for the applied account and returns asked, held and measured togetheroperate
send, cyclicputs a frame or a message on the bus, once or periodicallyoperate
stopdisarms everything and switches outputs offview

The set tool is the one place the agent's interface differs from a script's. A script is content with a sequence number. A model reasons better from the consequence, so the tool server follows the stream and answers with the three accounts after the write: "asked 12 V, the supply holds 6 V, 5.0 V measured".

What keeps it safe

  • The limits file. An agent cannot ask for more than limits.json allows, and no request changes that file. Set the limits for the device under test before giving an agent the operate role.
  • Its own name. The agent logs in with its own token, so every decision it takes is recorded under that name, and the rack shows its name on the instruments it holds.
  • The lease. While a person holds an instrument, the agent cannot write to it, and the other way round.
  • STOP. A person watching the rack can stop the bench at any time without holding anything.
  • A view-only agent is useful by itself: it can watch a run, read the recording and explain what happened, and the most it can do to the bench is stop it.

Two questions are open. Whether limits should be settable per name, so that an agent can be held to a narrower range than a person. And whether arming by an agent should need a person's confirmation on the rack.

1.7 The programs

ktestd: the rack daemon

ktestd [flags] interface

Owns one CAN interface and any number of instruments, records them, serves the rack and takes commands.

FlagDefaultMeaning
-o file<interface>.logbthe recording
-dbc filea CAN database: frames are decoded into signals beside the raw ones, and sends may name signals
-psu name=host:port[,channels]a bench supply to drive; repeatable
-scope name=host:port[,channels][,full=ch1+ch2]an oscilloscope to drive; repeatable. full= names the channels recorded at full rate
-panel filethe rack panel: served to the browser, saved to from it, embedded in the recording
-limits filebuilt inthe limits to enforce
-sock path/run/ktest.sockthe local control socket
-https addrcontrol, streams and the rack over TLS, with tokens
-tokens filethe names -https admits
-new-token name:rolemake a token and exit: the token on standard error, the line that admits it on standard output
-tls-cert, -tls-keyself-signeda certificate of your own
-http addrthe streams only, read-only, unauthenticated
-state dir~/.local/state/ktestdwhere the daemon keeps what it makes
-d duration0stop after this long; 0 runs until interrupted
-codeczstdcompression of the recording: none, deflate, zstd
-segment duration1 show often the recording restates its schemas and held values
-psu-poll, -scope-poll100 ms, 5 mshow often supplies are measured and scopes asked for a completed acquisition
-scope-sign, -scope-trigger-delaysub256, offtwo readings of the Siglent programming guide that change the numbers (§3.5)
-err, -rcvbuf, -qon, 1 MiB, offreceive error frames; the socket receive buffer; do not report what could not be carried across

canlogb: a CAN trace recorder

canlogb [-o file] [-dbc file] [-d duration] [-http addr] interface

Records a bus to a Logb file and can serve it live, and does nothing else. It has no control plane, so it cannot put anything on the bus and does not have to be trusted not to. Use it where a recorder is all that is wanted.

logbcheck: hold a recording to an independent account

logbcheck [-tx log] [-cyclic] [-control] [-psu name=transcript] [-scope name=transcript]
          recording.logb candump.log

Checks a recording against references that nothing in Ktest wrote: a candump log of the bus, a simulator's transcript. -control checks every control decision against the limits the recording itself declares. Give /dev/null as the log when there is no bus reference.

psusim, scopesim: instruments without hardware

psusim   [-addr :5025] [-channels 30:3:10,6:5:100] [-transcript file]
scopesim [-addr :5026] [-channels 'sine:amp=1,freq=1000;dc:amp=0'] [-on 1] [-transcript file]

psusim is a supply that speaks Keysight-style SCPI, clamps setpoints beyond a channel's rating and regulates into a resistive load (volts : amps : ohms per channel). scopesim is a Siglent SDS1202X-E with signals made from a description. Both keep a transcript for logbcheck.

vcanprobe: what this kernel and adapter can do

vcanprobe [-i vcan0] [-run regexp] [-list] [-json]

1.8 When ktestd refuses

A refusal is a JSON body with an error sentence and, when the decision was recorded, its seq. Refusals are recorded like everything else.

StatusMeaningWhat to do
400the request is malformed: an unknown key, a bad value, overlapping widgets in a panelread the sentence; it names the key
401no token, or one this daemon does not admitsend Authorization: Bearer <token>
403no lease presented; a view name asking to write; a setpoint above its limit; a cyclic period below the floortake the lease, use an operate token, or stay inside the limits file
404no such instrument, channel or cyclic task
409the instrument is not armed, or someone else holds its leasearm it; or wait, the rack shows who holds it
412a panel save from a panel no longer in forcereload and edit again
428a panel save without If-Matchsend the sha256 of the panel you edited
503the instrument is not answering or is busy, the kernel refused the frame, or the daemon is stoppingthe sentence carries the kernel's error

The daemon also refuses to start on a configuration it cannot stand behind: a misspelt key in the limits or the panel file, a limit for a channel no instrument declares, two instruments with one name, a widget in a box that belongs to another instrument. A key that was meant to do something and would do nothing is treated as an error, not ignored.

2 Setting up a bench

2.1 Channels

Everything on a bench is a channel in one flat namespace. Panels, scripts, limits and recordings all refer to channels by name, so names are fixed early and do not change.

EngineData.EngineSpeed       a bus signal, decoded by the DBC
can0.raw                     every frame on the bus
psu1.ch1.v.set               the voltage asked for
psu1.ch1.v.applied           the voltage the supply reports holding
psu1.ch1.meas.v              the voltage measured at the terminals
scope1.ch1                   a waveform
scope1.timebase.tdiv.set     seconds per division, asked for

A controlled quantity is kept as three accounts: what was asked (set), what the instrument accepted (applied) and what happened (meas). One channel for all three would make the recording wrong the first time an instrument clamps, refuses or trips. Kept apart, a recording answers "what did we ask for, what was accepted, what happened" without inference. The figure at the top of this manual is that, for one setpoint.

Kind of channelBehaviourExamples
helda record only when the value changes; the last value stands until the next. Restated at every segment, so a late reader knows the value in force.setpoints, armed state, lease holder
sampleda record per sample or per frame, each with its timebus frames, decoded signals, supply measurements
waveformdense samples on a uniform axis, one run per acquisitionscope1.ch1, scope1.ch1.env

A bus signal is named by its message today (EngineData.EngineSpeed). With a second bus the bus name will come first (can1.EngineData.EngineSpeed); that change is planned before multi-bus support.

2.2 Connecting a bus

Bring the interface up (§1.2), name it as the last argument, and give a DBC if there is one:

$ ktestd -dbc car.dbc -o run.logb can0

The bus is recorded in these streams:

StreamWhat it holds
<bus>.rawevery frame, with the kernel's receive timestamp, and whether it came from this host
<Message>with -dbc, each message decoded into its signals in physical units
<bus>.tx.seteach send the daemon was asked for, refused ones included
<bus>.tx.appliedeach of those frames, handed back by the kernel as sent
<bus>.cyclic.<id>each cyclic task: every change, with the kernel's own account of the task beside the request
<bus>.controlevery decision the control plane took for the bus
<bus>.dropsframes the kernel reported lost, as drops and never as silence

The DBC is embedded in the recording, so the file decodes itself later without it.

One bus per daemon today. Several buses, LIN (through a USB dongle or a Pico-based interface that carries CAN and LIN together), ISO-TP and J1939 (both through the kernel's own socket families, with no new hardware) are planned.

2.3 Connecting instruments

An instrument is named on the command line. The name is the first part of every one of its channels, so choose it for the bench and not for the model: psu1, not e36312a.

A supply

-psu psu1=192.168.1.20:5025,2        name = host:port, number of channels

SCPI over a raw TCP socket, the LXI port 5025, in the Keysight spelling (VOLT 5,(@1)) that the E36300 series and many others use.

ChannelWritableMeaning
psu1.chN.v.set, .v.appliedsetvoltage setpoint, asked and held
psu1.chN.i.set, .i.appliedsetcurrent limit, asked and held
psu1.chN.out.set, .out.appliedsetoutput on or off
psu1.chN.meas.v, .meas.imeasured at the terminals, every -psu-poll
psu1.chN.connectedwhether the supply is answering
psu1.controlleases, arming, every write and refusal

A setpoint nobody has set since the daemon started is absent from the recording, not zero. Whatever the supply's error queue said after a write is recorded beside it.

An oscilloscope

-scope scope1=192.168.1.30:5025,2,full=ch1      name = host:port, channels, full-rate channels

The Siglent SDS1202X-E dialect. The daemon arms the scope, waits for an acquisition, reads every displayed channel and starts again. Acquisition needs neither lease nor arming; changing a setting does.

ChannelWritableMeaning
scope1.chN.enveach acquisition as a min/max envelope, about 3000 columns; always recorded, and what the rack draws
scope1.chNevery sample, in volts; only for channels named in full=
scope1.chN.vert.vdiv.set, .ofst.set, .on.setsetvolts per division, offset, channel displayed
scope1.timebase.tdiv.setsetseconds per division; the delay and memory depth likewise
scope1.timebase.sarathe sample rate the scope chose
scope1.acqone record per channel per trigger, whatever became of it, drops included

Full rate is opt-in per channel because continuous full-rate capture should be a decision someone made, not a disk found full after an overnight run. One full-depth acquisition is 56 MB.

Status. Both drivers are built and checked against their simulators. Checking them against the real instruments, and placing a scope trigger on the recording's timeline by way of the scope's own CAN trigger, is the work in progress.

2.4 Adding an instrument

There are three cases, from a line of configuration to a new driver.

Another unit of a supported kind

Add another -psu or -scope with a new name. Each has its own lease, arming and control stream. Add its channels to the limits file and a box for it to the panel.

The same kind, another SCPI spelling

The commands a driver sends are a table, a dialect, and not code. A supply that spells things differently is a new table in psu/:

var Rigol = Dialect{
    Name:      "rigol",
    SetVolt:   ":SOUR%[1]d:VOLT %[2]s",     // argument 1 is the channel, 2 the value
    QueryVolt: ":SOUR%[1]d:VOLT?",
    SetCurr:   ":SOUR%[1]d:CURR %[2]s",
    QueryCurr: ":SOUR%[1]d:CURR?",
    SetOut:    ":OUTP CH%[1]d,%[2]s",
    QueryOut:  ":OUTP? CH%[1]d",
    MeasVolt:  ":MEAS:VOLT? CH%[1]d",
    MeasCurr:  ":MEAS:CURR? CH%[1]d",
    Identify:  "*IDN?", Clear: "*CLS", Err: ":SYST:ERR?",
}

Today this needs a rebuild and the daemon uses the Keysight table. Choosing the dialect per instrument, -psu psu1=host:port,2,dialect=rigol, and loading dialect tables from a file without rebuilding, are planned.

A new kind of instrument planned

Electronic loads, multimeters and function generators each need a driver, and USBTMC needs a transport beside TCP. A driver is a Go package, and the supply and the scope set the pattern that a new one follows:

  1. Write the simulator first. It speaks the instrument's dialect, misbehaves the way the instrument does (clamps, rounds, reports errors only in its queue), and keeps a transcript of what it was told. The daemon is then built and checked before the hardware is plugged in, and the hardware later shows where the simulator was wrong.
  2. Write the driver as a conversation. It says what the instrument was told and what it answered. It holds no limits, no leases and no opinion about what a setpoint should be; those are the daemon's.
  3. Name the channels with the three accounts: load1.ch1.i.set, .i.applied, load1.ch1.meas.v.
  4. Give it a control stream that states its kind, so that the rack and any other reader know what load1 is without guessing from its name.
  5. Decide what a stop does. An output goes off. An input is left alone.
  6. Add its limits to the limits file's vocabulary, and a rule to logbcheck that holds the recording to the simulator's transcript.

No new widget is needed. An instrument appears on the rack as a box of generic widgets bound to its channels, which is a panel and not code.

2.5 Limits: limits.json

The limits are the bounds no client can loosen, whatever role or lease it has. They are read once, at start. No request changes them.

{
  "min_period": "1ms",
  "channels": {
    "psu1.ch1":        {"v_max": 12, "i_max": 1},
    "scope1.timebase": {"msiz_max": 700000}
  }
}
KeyMeaningWithout it
min_periodthe shortest period a cyclic frame may have, so that no task can flood the bus1 ms
channels.<supply>.chN.v_max, i_maxthe highest voltage setpoint and current limit a client may ask forno limit, recorded as "none"
channels.<scope>.timebase.msiz_maxthe deepest memory a client may ask for, which bounds the size of a readoutno limit

Set the limits for the device on the bench, not for the instrument: a 30 V supply feeding a 12 V module gets v_max 12. The limits in force are written into every recording, and the file is attached to it. Limits as expressions over channels are planned.

2.6 Access: tokens, roles and TLS

$ ktestd -new-token rolf:operate >> tokens      # the token itself is printed on the terminal
$ ktestd -new-token display:view >> tokens
$ ktestd -https :8443 -tokens tokens can0

The tokens file holds one line per name, name role sha256. It never holds a token, so reading it admits nobody.

RoleMay
viewread the streams, open the rack, and stop
operatealso take leases, arm, write channels, transmit, and save panels

Without -tls-cert and -tls-key the daemon makes a self-signed certificate once, keeps it in its state directory and prints its fingerprint. A script should trust that certificate (curl --cacert ~/.local/state/ktestd/tls-cert.pem) or pin its key, and not switch verification off.

The recording lists the names and roles that were admitted, never the hashes, and every control decision records who asked and through which listener.

2.7 Panels: panel.json

A panel is the layout of a rack: boxes, one per instrument, each a grid of widgets bound to channels by name. It is data. The browser builds the rack from it, and the recording carries it.

{
  "title": "Bench: supply and scope",
  "groups": [
    {
      "instrument": "psu1",
      "label": "Bench supply / 30 V 3 A",
      "widgets": [
        {"type": "knob",   "channel": "psu1.ch1.v.set", "label": "ch1 voltage",
         "step": 0.1, "max": 12,                       "x": 0, "y": 0, "w": 2, "h": 3},
        {"type": "gauge",  "channel": "psu1.ch1.meas.v", "label": "ch1 measured",
         "max": 12,                                    "x": 2, "y": 0, "w": 3, "h": 3},
        {"type": "toggle", "channel": "psu1.ch1.out.set", "label": "ch1 output",
                                                       "x": 5, "y": 0, "w": 2, "h": 2},
        {"type": "plot",   "channels": ["psu1.ch1.meas.v", "psu1.ch1.meas.i"],
         "window": 20,                                 "x": 0, "y": 3, "w": 12, "h": 4}
      ]
    },
    {
      "instrument": "scope1",
      "label": "Oscilloscope / SDS1202X-E",
      "widgets": [
        {"type": "scope",    "channel": "scope1.ch1", "label": "C1",
                                                       "x": 0, "y": 0, "w": 8, "h": 5},
        {"type": "setpoint", "channel": "scope1.timebase.tdiv.set", "label": "Seconds/div",
                                                       "x": 8, "y": 0, "w": 4, "h": 2}
      ]
    }
  ]
}

The panel

KeyMeaning
titleshown at the top of the rack
columnsthe grid's width in cells, 1 to 48; 12 when unsaid
groupsthe boxes, drawn top to bottom in the order given
widgetsinstead of groups: one unnamed grid. A panel has one or the other.

A group

KeyMeaning
instrumentthe name the recording knows the instrument by. Every widget in the box must be bound to that instrument's channels. The box's header shows the instrument's kind, holder and arming; the kind comes from the recording and is never stated here.
labelwhat the box is called on screen
columnsthis box's grid width; the panel's when unsaid
widgetsthe widgets; x and y are the box's own, so a box moves up or down without its contents being renumbered

A widget

Every widget has type, a place (x, y, w, h in grid cells) and an optional label. Widgets lie inside the grid and clear of each other.

TypeBound toOther keys
readoutchannel
gaugechannelmin, max: the scale; 0 to 100 when unsaid
lampchannel
plotchannels, a listwindow: seconds shown
scopechannel, a scope channel such as scope1.ch1
setpointchannel, a number that can be setstep
knob, sliderchannel, a number that can be setmin, max, step
enumchannel, a number that can be setoptions: at least two {"value": 3.3, "label": "3V3 logic"}, no value twice
toggle, buttonchannel, a switch such as out.set or on.set

A scale says what is worth asking for and enforces nothing. A knob whose scale ends at 12 V does not stop a script from asking for 20; the limits file does.

Several widgets may be bound to one channel. A knob, a typed setpoint and a preset selector on psu1.ch1.v.set all follow each other, because each reads the value back from the recording and none from another.

The panel is checked when the daemon starts, and the daemon does not start on a panel it cannot stand behind. Whether a channel exists is not checked then, since a panel is written before its streams arrive and must replay against a recording; the rack says so beside the widget.

2.8 Designing a panel in the rack

The quickest way to a panel is to start the daemon with a nearly empty one and lay it out in the browser.

$ echo '{"title": "My bench", "groups": [{"instrument": "psu1", "widgets": []}]}' > rack.json
$ ktestd -https :8443 -tokens tokens -panel rack.json -psu psu1=192.168.1.20:5025,2 can0
  1. Press Edit panel. The widgets go on drawing live data while you edit.
  2. Add puts a new widget below the rest of its box. Click it to choose its type, channel and label.
  3. Drag a widget to move it. Drag its lower right corner to resize it. ✕ removes it.
  4. Save. The panel is checked by the daemon exactly as the file is at start, written back to the -panel file with one widget to a line, and embedded in the recording again. Every other page showing the rack changes with it.

If two people edit at once, the second save is refused and says why, because it was edited from a panel that is no longer in force. Reload and make the change again. A view name cannot save.

Give the daemon a copy of a panel you want to keep unchanged, as the demos do with rack.json made from panel.json, since a save overwrites the file it was started with.

Moving a widget from one box into another by dragging, and adding or reordering boxes in the editor, are planned. Today a widget changes boxes by being removed and added again, and boxes are added in the file.

Some advice on layout

  • One box per instrument, named as the bench names it. The header then tells you at a glance who holds what.
  • Put what is measured beside what is set. A knob for the voltage next to a gauge for the measured voltage shows a current limit biting the moment it happens.
  • Give every supply a lamp on connected and on out.applied. An output that is on should be visible from across the room.
  • Set a knob's or a slider's max to the limit in limits.json, so the scale covers what can be asked for.
  • Use an enum for the few values that have names on this bench (3V3, 5 V, 12 V rail) and a typed setpoint for the rest.
  • Use a button for anything that should be on only while someone is there holding it. It is a convenience and not an interlock; arming and STOP are the interlocks.

2.9 The recording

The recording is a Logb file: an open, MIT-licensed format for test data whose frames are self-describing, length-prefixed and checksummed. A Ktest recording is complete in itself. It contains:

ContentWhy
every channel, with its name, unit and scalingno database is needed to read it later
every control decision and refusal, with who askedthe file says what was done to the bench, not only what was seen
the DBC, the limits and the panel, as attachmentsit decodes itself, states what was allowed, and brings back the rack that watched it
rack.panelwhich panel was on screen when, with each save and who made it
provenance: interface and adapter, kernel and daemon versions, the timestamp source of each stream, instrument identification strings, the admitted names and the certificate's fingerprinta measurement from 2027 should be reconstructable in 2030
drops, as recordsa gap that does not say it is a gap is the worst thing a recording can contain

The file on disk and the bytes on /stream/live are the same format, so a recording cut anywhere is still readable: a reader finds the next segment and has the schemas and the held values it needs. Tools:

ToolFromUse
logbviewLogbbrowse a recording as plots; -follow for one still being written
logbdumpLogbprint a recording's frames and values as text
logbcheckKtesthold a recording to an independent reference (§1.7)

3 The design

3.1 Four properties

  1. The daemon owns the hardware. Connections stay open, access is leased, and a run survives the browser disconnecting.
  2. Everything produced is a Logb stream. The file on disk and the bytes on the wire are one format. Live view and replay share one decoder.
  3. Control is asymmetric. Commands go in over REST; results come back only on the stream. A widget is never the source of truth.
  4. Timing lives below the daemon. Cyclic transmit is configured into the kernel and timestamps are taken by it. The daemon's event loop is never what paces a bus.

The daemon is a plain user process in one static binary. It is Linux only; Windows and macOS reach it through the browser.

3.2 Commands in, results out

A request returns accepted or refused and a sequence number. It does not return state. The resulting set, applied and meas values arrive on the stream like everything else. What this buys:

  • Two browsers, a script and an agent see the same bench, because there is one account of it and none of them holds a private copy.
  • A page reload, or a client that connects halfway through a run, recovers the whole rack from the stream's opening segment. There is no "get current state" request that could disagree with the stream.
  • Replay drives the display through the same path as live.
  • The data connection is one-way, so it is an ordinary HTTP response and not a WebSocket. curl > run.logb is a recording, a slow client slows its own connection and nothing else, and a reconnect resynchronises by itself.

The cost is at the widget, which must show an unconfirmed value as unconfirmed and commit a drag on release instead of sending every intermediate value.

3.3 Safety

The browser is an actuator, and so is a script and so is an agent. Nothing is sent by default.

MechanismRule
Limitsper channel, in the daemon, read once at start, independent of what any panel or client permits
Leaseone writer per instrument, with a term. A client that stops renewing loses it, so a crashed script cannot block the lab.
Armingexplicit, per instrument. Only an armed instrument takes writes.
Stopanyone who can reach the daemon may disarm. Outputs go off; inputs are left alone.
Disconnectper class. When a lease ends, cyclic transmit stops, and a supply holds its output. If the daemon itself dies, the kernel stops every cyclic task it had.
Identityover the network, TLS and a named token on every request. A lease belongs to the name that took it.
Replaynever actuates planned

Every one of these decisions is recorded, refusals included, so the rules can be checked afterwards against the recording. logbcheck -control does that.

3.4 Timing

Ktest is not a real-time system and does not pretend to be. Two things are delegated to the kernel:

  • Cyclic transmit is CAN_BCM. Measured on a virtual bus at a 1 ms period, its frame-to-frame jitter is 31 µs at the median and 76 µs at the 99th percentile; a userspace loop manages 166 µs and 983 µs. A cyclic task keeps no timetable: each interval runs a few tens of µs long, so over minutes the count falls slightly short of duration over period.
  • Receive timestamps are the kernel's. A frame that arrives without one is recorded as unstamped, never as time zero. Hardware timestamps from the controller, with the recording saying which clock each stream is on, are planned with the MCP2518FD interface.

Reacting to a frame within microseconds is not something a script or the daemon's loop will ever do. It is kernel configuration or instrument hardware. Scripts are for orchestration at human and millisecond scales: sweep a parameter, step a supply, wait for a signal.

Bus frames are never dropped silently. When the kernel reports a loss, the loss is a record. When a reader simply stalls, the kernel reports nothing, so the daemon does not take silence as proof that nothing was lost.

3.5 Waveforms

An acquisition can be 14 million samples, and a browser has about a thousand pixels across. The daemon reduces each acquisition to a min/max envelope of some 3000 columns. A spike one sample wide is still in it, which every-nth-point decimation would lose. The rack draws the envelope as a band; the recording keeps the envelope always and the full samples for the channels you name.

A sample is stored as 32-bit float volts. Storing the digitiser's 8-bit code would be four times smaller, but then every turn of the volts/div knob would change the stream's schema and with it the stream's identity, and scope1.ch1 must mean one thing for a whole session. The code is not lost: the conversion in force is recorded with every acquisition, so it can be recovered exactly.

The settings an acquisition was taken under travel with it, and the rack shows those under each trace, not whatever the instrument holds by the time it is drawn. A change of the sample interval opens a new segment in the recording, so that no sample can be decoded to the wrong position.

If the rack's stream cannot keep up, an acquisition is left out of it and recorded as a drop in scope1.acq. The recording on disk still has it.

Two readings of Siglent's programming guide disagree between its revisions: how a code above 127 becomes negative, and whether the trigger delay shifts the time axis. They are settings (-scope-sign, -scope-trigger-delay), written into every acquisition, until the instrument settles them.

3.6 What Ktest is not

  • Not a production end-of-line system. No operator mode, no yield reporting, no shop-floor integration.
  • Not an IDE, a report generator or a requirements tool.
  • Not a real-time system. Sub-millisecond deterministic response belongs to the kernel or the instrument.
  • Not tied to one vendor's hardware. SocketCAN adapters and SCPI instruments; no Vector VN interfaces.
  • Not automotive Ethernet. That is a separate project.

3.7 Status and roadmap

Ktest is built in milestones, each usable alone. This manual describes the whole; this table says how much of it exists (ktestd 0.4).

MilestoneStatus
1Record. SocketCAN in, Logb out, live view, DBC decodingbuilt
2Transmit. Single and cyclic sends, by bytes or by signal; leases, arming, limits; TLS and rolesbuilt
3Panels. The rack, the widget set, the panel editor, the supply driverbuilt, except the trace table
4Scope and waveforms. The scope driver, envelopes, the scope widgetbuilt against the simulator
The real SDS1202X-E; hardware timestamps; the scope's trigger on the recording's timelinein progress
5Scripting. Lua in the daemon, then limit expressionsplanned
Replay in the rack; packaging as a service; more instrument kinds and dialects by configuration; USBTMCplanned
Several buses; LIN; ISO-TP; J1939planned
The agent tool serverproposed

The design is written down in full, with its measurements and the decisions that were rejected, in doc/test-software-outline.md.