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.
- 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,scope1andcan0are 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:
| Work | What Ktest does | Status |
|---|---|---|
| Bus | monitors CAN and CAN FD, decodes with a DBC, sends single frames and kernel-paced cyclic ones, by bytes or by signal | built |
| LIN, ISO-TP, J1939 | planned | |
| Bench | drives programmable supplies and oscilloscopes over SCPI | built against simulators |
| loads, multimeters, function generators; USBTMC | planned | |
| Recording | puts all of it on one timeline, in one open format, Logb | built |
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.
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:
| Path | What it holds |
|---|---|
/usr/bin/ktestd | the daemon |
/etc/ktest/limits.json | the limits no client can loosen (§2.4) |
/etc/ktest/tokens | who may log in, as hashes (§2.5) |
/etc/ktest/panels/*.json | rack panels (§2.6) |
/run/ktest.sock | the local control socket, mode 0660 |
~/.local/state/ktestd/ | what the daemon makes for itself, such as its TLS certificate |
/var/lib/ktest/*.logb | recordings |
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:
- Press Take control, then Arm. The supply's widgets unlock. The scope's traces were already drawing, because reading an input needs no permission.
- 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.
- 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.
- Ask for 20 V. The request is refused, because
limits.jsoncaps the channel at 12 V. The refusal is in the recording. - 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.
- 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
| Control | What it does |
|---|---|
| Take control | takes 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). |
| Arm | arms the instruments you hold. Nothing is sent or set on an instrument that is not armed. |
| STOP | disarms 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 panel | turns 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.
| Widget | Shows or sets | Notes |
|---|---|---|
| readout | one value, as a number with its unit | |
| gauge | one value on a scale | |
| lamp | one on/off or state value | |
| plot | several channels against time, scrolling | channels from different sources share one axis |
| scope | one oscilloscope channel, per acquisition | drawn as a min/max band, so a one-sample spike is still visible; the trigger is the dashed line |
| setpoint | a number, typed | |
| knob, slider | a number, dragged or stepped with the arrow keys | sends on release, so a drag is one decision in the recording |
| enum | one of a list of named values | shows no choice when the held value is not on the list |
| toggle | on or off | |
| button | on while held | releases when the pointer leaves, the window loses focus or the tab is hidden |
| trace table | a bus's frames as they arrive, decoded | planned |
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:
| Listener | Flag | Who may use it |
|---|---|---|
| Unix socket | -sock /run/ktest.sock | whoever the file's owner and group admit; full control, no token |
| HTTPS | -https :8443 -tokens file | named tokens with a role; control and streams |
| HTTP | -http :8080 | anyone; 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
| Tool | What it does | Role needed |
|---|---|---|
bench | lists the instruments, their kind, who holds each and whether it is armed, and the limits in force | view |
channels | lists channels with unit and current value; for a settable one, the set and the applied account | view |
read | returns a channel over a time window, reduced to at most a few hundred points with min and max kept | view |
waveform | returns the latest acquisition of a scope channel as an envelope, with its settings and simple measurements (peak to peak, mean, frequency) | view |
take_control, release | takes or gives back an instrument's lease; the server renews it while the session lives | operate |
arm | arms an instrument the agent holds | operate |
set | writes a settable channel, then waits for the applied account and returns asked, held and measured together | operate |
send, cyclic | puts a frame or a message on the bus, once or periodically | operate |
stop | disarms everything and switches outputs off | view |
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.jsonallows, and no request changes that file. Set the limits for the device under test before giving an agent theoperaterole. - 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.
| Flag | Default | Meaning |
|---|---|---|
-o file | <interface>.logb | the recording |
-dbc file | a 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 file | the rack panel: served to the browser, saved to from it, embedded in the recording | |
-limits file | built in | the limits to enforce |
-sock path | /run/ktest.sock | the local control socket |
-https addr | control, streams and the rack over TLS, with tokens | |
-tokens file | the names -https admits | |
-new-token name:role | make a token and exit: the token on standard error, the line that admits it on standard output | |
-tls-cert, -tls-key | self-signed | a certificate of your own |
-http addr | the streams only, read-only, unauthenticated | |
-state dir | ~/.local/state/ktestd | where the daemon keeps what it makes |
-d duration | 0 | stop after this long; 0 runs until interrupted |
-codec | zstd | compression of the recording: none, deflate, zstd |
-segment duration | 1 s | how often the recording restates its schemas and held values |
-psu-poll, -scope-poll | 100 ms, 5 ms | how often supplies are measured and scopes asked for a completed acquisition |
-scope-sign, -scope-trigger-delay | sub256, off | two readings of the Siglent programming guide that change the numbers (§3.5) |
-err, -rcvbuf, -q | on, 1 MiB, off | receive 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.
| Status | Meaning | What to do |
|---|---|---|
400 | the request is malformed: an unknown key, a bad value, overlapping widgets in a panel | read the sentence; it names the key |
401 | no token, or one this daemon does not admit | send Authorization: Bearer <token> |
403 | no lease presented; a view name asking to write; a setpoint above its limit; a cyclic period below the floor | take the lease, use an operate token, or stay inside the limits file |
404 | no such instrument, channel or cyclic task | |
409 | the instrument is not armed, or someone else holds its lease | arm it; or wait, the rack shows who holds it |
412 | a panel save from a panel no longer in force | reload and edit again |
428 | a panel save without If-Match | send the sha256 of the panel you edited |
503 | the instrument is not answering or is busy, the kernel refused the frame, or the daemon is stopping | the 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 channel | Behaviour | Examples |
|---|---|---|
| held | a 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 |
| sampled | a record per sample or per frame, each with its time | bus frames, decoded signals, supply measurements |
| waveform | dense samples on a uniform axis, one run per acquisition | scope1.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:
| Stream | What it holds |
|---|---|
<bus>.raw | every 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.set | each send the daemon was asked for, refused ones included |
<bus>.tx.applied | each 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>.control | every decision the control plane took for the bus |
<bus>.drops | frames 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.
| Channel | Writable | Meaning |
|---|---|---|
psu1.chN.v.set, .v.applied | set | voltage setpoint, asked and held |
psu1.chN.i.set, .i.applied | set | current limit, asked and held |
psu1.chN.out.set, .out.applied | set | output on or off |
psu1.chN.meas.v, .meas.i | measured at the terminals, every -psu-poll | |
psu1.chN.connected | whether the supply is answering | |
psu1.control | leases, 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.
| Channel | Writable | Meaning |
|---|---|---|
scope1.chN.env | each acquisition as a min/max envelope, about 3000 columns; always recorded, and what the rack draws | |
scope1.chN | every sample, in volts; only for channels named in full= | |
scope1.chN.vert.vdiv.set, .ofst.set, .on.set | set | volts per division, offset, channel displayed |
scope1.timebase.tdiv.set | set | seconds per division; the delay and memory depth likewise |
scope1.timebase.sara | the sample rate the scope chose | |
scope1.acq | one 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:
- 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.
- 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.
- Name the channels with the three accounts:
load1.ch1.i.set,.i.applied,load1.ch1.meas.v. - Give it a control stream that states its kind, so that the rack and any other reader know what
load1is without guessing from its name. - Decide what a stop does. An output goes off. An input is left alone.
- Add its limits to the limits file's vocabulary, and a rule to
logbcheckthat 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}
}
}
| Key | Meaning | Without it |
|---|---|---|
min_period | the shortest period a cyclic frame may have, so that no task can flood the bus | 1 ms |
channels.<supply>.chN.v_max, i_max | the highest voltage setpoint and current limit a client may ask for | no limit, recorded as "none" |
channels.<scope>.timebase.msiz_max | the deepest memory a client may ask for, which bounds the size of a readout | no 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.
| Role | May |
|---|---|
view | read the streams, open the rack, and stop |
operate | also 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
| Key | Meaning |
|---|---|
title | shown at the top of the rack |
columns | the grid's width in cells, 1 to 48; 12 when unsaid |
groups | the boxes, drawn top to bottom in the order given |
widgets | instead of groups: one unnamed grid. A panel has one or the other. |
A group
| Key | Meaning |
|---|---|
instrument | the 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. |
label | what the box is called on screen |
columns | this box's grid width; the panel's when unsaid |
widgets | the 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.
| Type | Bound to | Other keys |
|---|---|---|
readout | channel | |
gauge | channel | min, max: the scale; 0 to 100 when unsaid |
lamp | channel | |
plot | channels, a list | window: seconds shown |
scope | channel, a scope channel such as scope1.ch1 | |
setpoint | channel, a number that can be set | step |
knob, slider | channel, a number that can be set | min, max, step |
enum | channel, a number that can be set | options: at least two {"value": 3.3, "label": "3V3 logic"}, no value twice |
toggle, button | channel, 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
- Press Edit panel. The widgets go on drawing live data while you edit.
- Add puts a new widget below the rest of its box. Click it to choose its type, channel and label.
- Drag a widget to move it. Drag its lower right corner to resize it. ✕ removes it.
- Save. The panel is checked by the daemon exactly as the file is at start, written back to the
-panelfile 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
connectedand onout.applied. An output that is on should be visible from across the room. - Set a knob's or a slider's
maxto the limit inlimits.json, so the scale covers what can be asked for. - Use an
enumfor the few values that have names on this bench (3V3, 5 V, 12 V rail) and a typedsetpointfor the rest. - Use a
buttonfor 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:
| Content | Why |
|---|---|
| every channel, with its name, unit and scaling | no database is needed to read it later |
| every control decision and refusal, with who asked | the file says what was done to the bench, not only what was seen |
| the DBC, the limits and the panel, as attachments | it decodes itself, states what was allowed, and brings back the rack that watched it |
rack.panel | which 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 fingerprint | a measurement from 2027 should be reconstructable in 2030 |
| drops, as records | a 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:
| Tool | From | Use |
|---|---|---|
logbview | Logb | browse a recording as plots; -follow for one still being written |
logbdump | Logb | print a recording's frames and values as text |
logbcheck | Ktest | hold a recording to an independent reference (§1.7) |
3 The design
3.1 Four properties
- The daemon owns the hardware. Connections stay open, access is leased, and a run survives the browser disconnecting.
- 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.
- Control is asymmetric. Commands go in over REST; results come back only on the stream. A widget is never the source of truth.
- 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.logbis 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.
| Mechanism | Rule |
|---|---|
| Limits | per channel, in the daemon, read once at start, independent of what any panel or client permits |
| Lease | one writer per instrument, with a term. A client that stops renewing loses it, so a crashed script cannot block the lab. |
| Arming | explicit, per instrument. Only an armed instrument takes writes. |
| Stop | anyone who can reach the daemon may disarm. Outputs go off; inputs are left alone. |
| Disconnect | per 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. |
| Identity | over the network, TLS and a named token on every request. A lease belongs to the name that took it. |
| Replay | never 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).
| Milestone | Status | |
|---|---|---|
| 1 | Record. SocketCAN in, Logb out, live view, DBC decoding | built |
| 2 | Transmit. Single and cyclic sends, by bytes or by signal; leases, arming, limits; TLS and roles | built |
| 3 | Panels. The rack, the widget set, the panel editor, the supply driver | built, except the trace table |
| 4 | Scope and waveforms. The scope driver, envelopes, the scope widget | built against the simulator |
| The real SDS1202X-E; hardware timestamps; the scope's trigger on the recording's timeline | in progress | |
| 5 | Scripting. Lua in the daemon, then limit expressions | planned |
| Replay in the rack; packaging as a service; more instrument kinds and dialects by configuration; USBTMC | planned | |
| Several buses; LIN; ISO-TP; J1939 | planned | |
| The agent tool server | proposed |
The design is written down in full, with its measurements and the decisions that were rejected, in doc/test-software-outline.md.