Skip to content

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

PropertyTypeAccessDescription
connectedboolRWhether the transport is currently open
identitystringRMost recent *IDN? response
transportstringR"tcp", "serial", or "usbtmc"
lastCommandstringRLast command sent (helpful for debugging)
lastResponsestringRLast response received
queryCountintRTotal queries executed since init
errorsstring[]RError-queue snapshot (populated by readErrors)

Actions

ActionArgsDescription
connectOpen the transport and query *IDN?
disconnectClose the transport
identifyQuery *IDN? again (refreshes identity)
resetSend *RST
clearSend *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)
readErrorsDrain SYST:ERR? into the errors queue (max 20)
opcQuery *OPC?
stbQuery *STB? (parsed as int when possible)
measure{ function: string }Shorthand for MEAS:<function>? — parses numeric responses

Streams

StreamFormatRateDescription
scpitextper-transactionTimestamped TX/RX log, only emitted when debug: true

Config Options

OptionTypeDefaultDescription
transportstring"tcp""tcp", "serial", or "usbtmc"
hoststring"192.0.2.100"TCP host / IP (placeholder — set to your instrument)
portint5025TCP port (standard SCPI raw socket)
serialPortstring"COM3"Serial port name for transport: "serial"
baudRateint9600Serial baud rate
vendorIdint / "0xNNNN"USBTMC USB Vendor ID — required when transport: "usbtmc"
productIdint / "0xNNNN"USBTMC USB Product ID — required when transport: "usbtmc"
serialstring""USBTMC serial-number filter — leave empty to match the first device with matching VID/PID
interfaceNumberint0USBTMC interface to claim — almost always 0
endpointNumberint1USBTMC bulk endpoint number — almost always 1
terminatorstring"\n"Line terminator for both TX and RX
timeoutint5000Query timeout in milliseconds
autoConnectboolfalseConnect during init (also queries *IDN?)
initCommandsstring""Semicolon-separated commands sent after connect (e.g. "SYST:REM;*CLS")
commandDelayint0Minimum ms between consecutive transport operations — use for slow instruments
settleMsint0Delay held inside the transport lock after every non-query write, so a set-then-query sequence can read back the freshly applied value
debugboolfalseEmit TX/RX to the scpi stream and to server stdout
scpiobjectDeclarative 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.

javascript
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:

FieldTypeDescription
cmdstringBase 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.
unitstringOptional unit label surfaced in the schema and UI.
descriptionstringOptional human-readable description.
readonlyboolExplicitly mark read-only (same effect as a ? suffix on cmd).

On write, the driver sends {cmd} {value} (e.g. c.voltage = 5VOLT 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:

FieldTypeDescription
cmdstringSCPI 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.
descriptionstringOptional 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:

javascript
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:

  1. 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.
  2. Auto-resync on failure. If a query times out, fails to parse, or returns an empty line, the transport flips a _needsResync flag. The next transaction performs an aggressive drain before sending, so the late reply is swallowed instead of mis-attributed.
  3. One-shot retry. query catches TimeoutException / InvalidOperationException once 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.

javascript
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:

  1. 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.
  2. 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_OUT framing (Bulk-OUT) for writes, with 4-byte payload alignment and EOM = 1.
  • REQUEST_DEV_DEP_MSG_IN + DEV_DEP_MSG_IN framing (Bulk-OUT request, Bulk-IN response) for reads, with multi-transfer accumulation until the terminator appears or EOM is set.
  • INITIATE_CLEAR / CHECK_CLEAR_STATUS control transfers on connect to put the device in a known state.
  • INITIATE_ABORT_BULK_IN control 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

javascript
// 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

javascript
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 \r alone). You can also watch the raw traffic by setting debug: true and 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 increase timeout so 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 connectedautoConnect: false is the default. Either flip it on, or call connect from the UI / a startup script.

Muxit — Hardware Orchestration Platform