Scpi
Generic SCPI driver for any SCPI-compliant instrument — oscilloscopes, multimeters, function generators, power supplies, spectrum analyzers. Talks to the device over TCP/IP (LXI, raw socket port 5025), Serial (RS-232 / USB-to-serial), or USBTMC (direct USB-B port on modern bench instruments) and exposes raw query/send plus a handful of convenience actions (*IDN?, *RST, MEAS?, error-queue drain, …). Device-specific properties and methods belong in the connector config — this driver is the transport, not the instrument.
Scpi is a free tier-3 extension driver. It ships as .muxdriver and installs into workspace/drivers/.
Properties
| Property | Type | Access | Description |
|---|---|---|---|
connected | bool | R | Whether the transport is currently open |
identity | string | R | Most recent *IDN? response |
transport | string | R | "tcp", "serial", or "usbtmc" |
lastCommand | string | R | Last command sent (helpful for debugging) |
lastResponse | string | R | Last response received |
queryCount | int | R | Total queries executed since init |
errors | string[] | R | Error-queue snapshot (populated by readErrors) |
Actions
| Action | Args | Description |
|---|---|---|
connect | — | Open the transport and query *IDN? |
disconnect | — | Close the transport |
identify | — | Query *IDN? again (refreshes identity) |
reset | — | Send *RST |
clear | — | Send *CLS (clear status) |
query | { command: string } | Send a SCPI query and return the trimmed line response |
send | { command: string } | Send a SCPI command (no response expected) |
readErrors | — | Drain SYST:ERR? into the errors queue (max 20) |
opc | — | Query *OPC? |
stb | — | Query *STB? (parsed as int when possible) |
measure | { function: string } | Shorthand for MEAS:<function>? — parses numeric responses |
Streams
| Stream | Format | Rate | Description |
|---|---|---|---|
scpi | text | per-transaction | Timestamped TX/RX log, only emitted when debug: true |
Config Options
| Option | Type | Default | Description |
|---|---|---|---|
transport | string | "tcp" | "tcp", "serial", or "usbtmc" |
host | string | "192.0.2.100" | TCP host / IP (placeholder — set to your instrument) |
port | int | 5025 | TCP port (standard SCPI raw socket) |
serialPort | string | "COM3" | Serial port name for transport: "serial" |
baudRate | int | 9600 | Serial baud rate |
vendorId | int / "0xNNNN" | — | USBTMC USB Vendor ID — required when transport: "usbtmc" |
productId | int / "0xNNNN" | — | USBTMC USB Product ID — required when transport: "usbtmc" |
serial | string | "" | USBTMC serial-number filter — leave empty to match the first device with matching VID/PID |
interfaceNumber | int | 0 | USBTMC interface to claim — almost always 0 |
endpointNumber | int | 1 | USBTMC bulk endpoint number — almost always 1 |
terminator | string | "\n" | Line terminator for both TX and RX |
timeout | int | 5000 | Query timeout in milliseconds |
autoConnect | bool | false | Connect during init (also queries *IDN?) |
initCommands | string | "" | Semicolon-separated commands sent after connect (e.g. "SYST:REM;*CLS") |
commandDelay | int | 0 | Minimum ms between consecutive transport operations — use for slow instruments |
settleMs | int | 0 | Delay held inside the transport lock after every non-query write, so a set-then-query sequence can read back the freshly applied value |
debug | bool | false | Emit TX/RX to the scpi stream and to server stdout |
scpi | object | — | Declarative schema: auto-synthesizes properties and methods from a { cmd, type, … } map so the connector stays short |
Declarative Schema
Most SCPI connectors repeat the same pattern: cmd: "VOLT" for set, cmd: "VOLT?" for read, parseFloat on the response, Number(value) on the way out. The config.scpi block collapses all of that into a single declaration — the driver synthesizes the get/set/dispatch at init time and exposes them through the normal property/action pipeline.
config: {
transport: "tcp",
host: "192.0.2.100",
autoConnect: true,
scpi: {
properties: {
voltage: { cmd: "VOLT", type: "float", unit: "V", description: "Programmed voltage" },
current: { cmd: "CURR", type: "float", unit: "A" },
output: { cmd: "OUTP", type: "bool" },
measured_voltage: { cmd: "MEAS:VOLT?", type: "float", unit: "V" }, // read-only
},
methods: {
local: { cmd: "SYST:LOC", description: "Return to local control" },
beep: { cmd: "SYST:BEEP" },
save: { cmd: "*SAV", arg: "int", description: "Save to memory slot (c.save(N) → *SAV N)" },
},
},
},Property entries
Each scpi.properties.<name> entry accepts:
| Field | Type | Description |
|---|---|---|
cmd | string | Base SCPI command, e.g. "VOLT". Trailing ? marks the property read-only ("MEAS:VOLT?" → only a query is issued). |
type | "float" | "int" | "bool" | "string" | Response parser and write-formatter. Bool normalises "1"/"ON"/"TRUE" on read and sends ON/OFF on write. |
unit | string | Optional unit label surfaced in the schema and UI. |
description | string | Optional human-readable description. |
readonly | bool | Explicitly mark read-only (same effect as a ? suffix on cmd). |
On write, the driver sends {cmd} {value} (e.g. c.voltage = 5 → VOLT 5). On read, it sends {cmd}? and parses the response per type. Numeric parsers throw on NaN/Infinity so the transport's one-retry path kicks in — no garbled values leak into the state store.
Method entries
Each scpi.methods.<name> entry accepts:
| Field | Type | Description |
|---|---|---|
cmd | string | SCPI command to send when the method is invoked. |
arg | "int" | "float" | "bool" | "string" | { name, type } | Optional argument appended to cmd as {cmd} {value}. Omit for a no-arg method. |
description | string | Optional description. |
A declared method sends its command fire-and-forget (no response). If you need a query-and-parse method, write it by hand in the top-level methods block using c.query({ command }).
Precedence & overrides
Top-level properties.<name> and methods.<name> in the connector config override any declared entry with the same name. Use this to keep the declarative form for the regular cases and hand-write the quirks:
config: {
scpi: {
properties: {
voltage: { cmd: "VOLT", type: "float", unit: "V" },
},
},
},
properties: {
// Override: this instrument prefixes the value with "V:"
voltage: {
get: async () => parseFloat((await driver.query({ command: "VOLT?" })).replace(/^V:/, "")),
set: (v) => driver.send({ command: `VOLT ${Number(v)}` }),
},
},Declared properties are polled at the same default rate as any other driver property. If you need an un-polled property, declare it in the top-level properties block instead (where poll: false is the default).
Framing & Recovery
SCPI over raw sockets (and serial) has no built-in request/response pairing — a late reply from a timed-out query can shift every subsequent answer by one and the driver ends up returning "object [0]" / NaN for every read. This driver defends against that in three places:
- Drain-before-write. Every transaction discards any inbound bytes currently buffered before sending the command. Prevents a stale push or status prompt from being consumed as the response.
- Auto-resync on failure. If a query times out, fails to parse, or returns an empty line, the transport flips a
_needsResyncflag. The next transaction performs an aggressive drain before sending, so the late reply is swallowed instead of mis-attributed. - One-shot retry.
querycatchesTimeoutException/InvalidOperationExceptiononce and re-issues the command with the now-clean RX buffer. Retries are not silent for arbitrary errors — only for the two recoverable ones.
The settleMs option extends this with a post-write pause inside the transaction lock, useful on instruments that take a handful of ms to apply a setpoint before they'll honour the next command (e.g. "set voltage, then query it back").
USBTMC
USBTMC (USB Test & Measurement Class) is the USB-native protocol that most modern bench instruments use for their front-panel USB-B port — Rigol DS1000Z/MSO5000, Keysight DSO-X, Siglent SDG/SSA/SDS, Owon, most R&S. It's not a virtual COM port; the instrument exposes bulk USB endpoints and a small class-specific control channel, and the driver wraps every SCPI line in a USBTMC message header before sending.
config: {
transport: "usbtmc",
vendorId: 0x1AB1, // Rigol — find yours with the commands below
productId: 0x04CE,
// serial: "DS1ZA1234567890", // optional, only if you have multiple of the same model
autoConnect: true,
terminator: "\n",
},Finding VID/PID
Open Device Manager → your instrument → Properties → Details tab → Hardware Ids, formatted as USB\VID_vvvv&PID_pppp. The AI chat's list_usbtmc_devices tool also enumerates attached USBTMC endpoints with their VID/PID.
Platform setup
USBTMC instruments on Windows typically ship with a vendor driver (NI-VISA, Keysight IO Libraries Suite, Rigol's USB driver). Those drivers bind exclusively and block libusb. You have two options:
- Replace the driver with WinUSB using Zadig. Select your instrument, pick WinUSB, click Replace Driver. This is a one-time operation but it will break the vendor's own software. Reversible via Device Manager → Uninstall → rescan.
- Stick with TCP if the instrument has a LAN port. Most Keysight, Rigol, and Siglent gear supports raw socket on :5025 over Ethernet with no driver drama.
What's implemented
The driver speaks the base USBTMC protocol defined in USB-IF USBTMC rev 1.00 — enough for SCPI read/write against every mainstream instrument:
DEV_DEP_MSG_OUTframing (Bulk-OUT) for writes, with 4-byte payload alignment andEOM = 1.REQUEST_DEV_DEP_MSG_IN+DEV_DEP_MSG_INframing (Bulk-OUT request, Bulk-IN response) for reads, with multi-transfer accumulation until the terminator appears orEOMis set.INITIATE_CLEAR/CHECK_CLEAR_STATUScontrol transfers on connect to put the device in a known state.INITIATE_ABORT_BULK_INcontrol transfer during drain-before-write recovery.
USB488 extensions (SRQ on the interrupt endpoint, REN/GTL control requests, serial-poll status byte via the INTR-IN pipe) are not implemented yet — *STB? still works because it's a regular SCPI query that rides the normal Bulk-IN path.
Example Connector
// workspace/connectors/psu.js — BK Precision 9115 over serial
export default {
driver: "GenericScpi",
ai: {
instructions: "Benchtop PSU — confirm before changing output state or voltage above 30 V.",
},
config: {
transport: "serial",
serialPort: "COM5",
baudRate: 9600,
terminator: "\n",
timeout: 2000,
commandDelay: 40, // this PSU is slow
settleMs: 20, // let setpoints apply before read-back
autoConnect: true,
initCommands: "SYST:REM;*CLS",
// Declarative schema — driver synthesizes voltage/current/output
// properties and the small "send a fixed command" methods.
scpi: {
properties: {
voltage: { cmd: "VOLT", type: "float", unit: "V" },
current: { cmd: "CURR", type: "float", unit: "A" },
output: { cmd: "OUTP", type: "bool" },
measured_voltage: { cmd: "MEAS:VOLT?", type: "float", unit: "V" },
measured_current: { cmd: "MEAS:CURR?", type: "float", unit: "A" },
},
methods: {
local: { cmd: "SYST:LOC" },
beep: { cmd: "SYST:BEEP" },
save: { cmd: "*SAV", arg: "int" },
},
},
},
poll: ["voltage", "current", "output", "measured_voltage", "measured_current"],
methods: {
on: [() => { driver.output = true; }, "Enable output"],
off: [() => { driver.output = false; }, "Disable output"],
},
properties: {
// Computed power — pure JS on top of declared props
power: {
get: () => Math.round(driver.measured_voltage * driver.measured_current * 1000) / 1000,
description: "Calculated output power (W)",
},
},
};The more generic starter lives in the driver's template.js and picks up whichever transport you configure.
Example Script
const psu = connector("psu");
psu.on();
psu.voltage = 12.0;
psu.current = 0.5;
await delay(500);
log.info(`Output: ${psu.measured_voltage.toFixed(3)} V @ ${psu.measured_current.toFixed(3)} A`);
psu.off();Troubleshooting
Empty SCPI response for query: …— the instrument isn't replying to this verb. Check it's SCPI-compliant, and that the terminator matches the device (some use\r\n, a few use\ralone). You can also watch the raw traffic by settingdebug: trueand opening the scpi stream in a Terminal widget.- Every reading "by one" off — almost always a stale reply from a previous timed-out query. The auto-resync should catch this on the next transaction; if not, raise
commandDelay(20–50 ms) to slow the transaction rate, or increasetimeoutso the original query has time to finish cleanly. - Set-then-query returns the old value — add
settleMs: 10(or more). The driver will hold the lock for that long after any write, so the next query sees the applied setpoint. - USBTMC: "No USB device matched vendorId=… productId=…" — the instrument isn't visible to libusb. The vendor driver is probably still bound — use Zadig to switch to WinUSB.
- USBTMC: "Failed to open the USB device" — another process (vendor utility, NI-VISA, a stale Muxit session) is holding the device. Close any other SCPI tools and try again.
Not connected—autoConnect: falseis the default. Either flip it on, or callconnectfrom the UI / a startup script.