About
In this post, I’ll show you how to control your electronics lab equipment over the network using SCPI commands.
To quote Wikipedia:
“The Standard Commands for Programmable Instruments defines a standard for syntax and commands to use in controlling programmable test and measurement devices, such as automatic test equipment and electronic test equipment.”
“The physical hardware communications link (physical layer) is not defined by SCPI. While it was originally created for the IEEE-488.1 (GPIB) bus, SCPI can also be used with RS-232, RS-422, RS-485, USB, Ethernet, VXIbus, HiSLIP, etc.”
Generally, I would recommend you use the vendor tools so you don’t have to make/code anything on your own. However, you might need different software for each manufacturer, you might have top pay for some of the software, maybe you want some extra features that are not included, or you might want to integrate the control into your own system. This was the case for me because I am working on an MES(manufacturing execution system) application that needs to be able to control any potential measurement equipment on an assembly line, for example.
For this reason, I made a library(available as a NuGet package), additionally, I made two UIs for it, a Windows app and a web app in a Docker container. More about the app at the end of the post.
Note: Many years ago, I remember using LabVIEW for controlling my oscilloscope. However, when I looked into using it again a few years ago, I stumbled upon this issue, which is why I wouldn’t recommend using it.
SCPI in more detail, commands and how to send them
The abbreviation rule catches nearly everyone: FUNCtion is not a typo and not house style. It means send the capitals, or send the whole word. Nothing in between.
# Plain ASCII over a socket. No library needed: nc 192.168.1.19 5025 # A line ending in ? is a query and answers back. Everything else just does something. #=== EVERY INSTRUMENT ========================================================= # IEEE 488.2 common commands. Universal enough that *IDN? and *RST appear in all # 36 vendor catalogs I keep; *OPC? in 35, *CLS in 34. *IDN? # identify -> Siglent Technologies,SDM3065X,SDM36HCD801207,3.02.01.13 *RST # reset to a known state. always start a script here *CLS # clear the status registers and the error queue *OPC? # -> 1 only once the command queue drains. your sync primitive *WAI # same idea, but blocks the instrument instead of you *ESR? # standard event status register: what went wrong *STB? # status byte *TST? # run the self-test -> 0 means it passed *OPT? # which options are installed *SAV 1 # store the current front-panel setup in slot 1 *RCL 1 # ...and get it back SYSTem:ERRor? # -> 0,"No error" pops ONE entry per read. read it in a loop SYSTem:VERSion? # which SCPI revision the firmware claims #=== MULTIMETER =============================================================== # MEASure:* is the lazy one-shot: it configures, triggers and reads in one go. MEAS:VOLT:DC? # -> +8.39421150E+00 MEAS:VOLT:AC? MEAS:CURR:DC? MEAS:RES? # 2-wire. MEAS:FRES? is 4-wire MEAS:FREQ? MEAS:CAP? MEAS:TEMP? # Split it apart when you're measuring in a loop. Configure once, read many: CONF:VOLT:DC AUTO # set up, but do NOT trigger READ? # trigger and return INIT # trigger into memory... FETC? # ...and collect later VOLT:DC:NPLC 10 # integration time in line cycles. bigger = slower + quieter VOLT:DC:RANG:AUTO ON #=== POWER SUPPLY ============================================================= APPL CH1,5,1 # 5 V with a 1 A limit, in one line OUTP:STAT ON MEAS:VOLT:DC? CH1 # what it is ACTUALLY delivering, not what you asked for MEAS:CURR:DC? CH1 MEAS:POWE:DC? CH1 #=== FUNCTION GENERATOR ======================================================= SOUR1:FUNC SIN # SIN SQU RAMP PULS NOIS DC ARB SOUR1:FREQ 1000 SOUR1:VOLT 4 OUTP1:LOAD 50 # or INF for high-Z. get this wrong and amplitude is 2x off OUTP1:STAT ON #=== OSCILLOSCOPE ============================================================= :AUToscale # the "just show me something" button :RUN :STOP :SINGle :CHAN1:DISP ON :CHAN1:SCAL 1 # volts per division :CHAN1:COUP DC # AC | DC | GND :TIM:MAIN:SCAL 1e-3 # seconds per division. A 1 kHz sine needs ~1e-4 to be visible :TRIG:SWE AUTO # AUTO | NORMal | SINGle :MEAS:VPP? CHAN1 # -> 4.16E+00 :MEAS:VRMS? CHAN1 :MEAS:FREQ? CHAN1 # Pulling the actual samples off, and the screen as a PNG: :WAV:SOUR CHAN1 :WAV:FORM BYTE # WORD | BYTE | ASCii :WAV:DATA? # -> #9000001000<binary> 488.2 block: #, len-of-len, len, data :DISP:DATA? # screenshot, same block framing. read by length, never to a timeout #=== ELECTRONIC LOAD ========================================================== INP:STAT ON MEAS:VOLT? MEAS:CURR? MEAS:POW? #=== THE THREE THINGS THAT WILL BITE YOU ====================================== # 1. Mixed case in the manual marks the abbreviation. Same command, both legal: CONFigure:VOLTage:DC AUTO CONF:VOLT:DC AUTO # ...but CONFig is an error. Capitals, or the whole word. # 2. Nothing is synchronous. Set, wait, THEN measure, or you read the old value. SOUR1:VOLT 4 *OPC? # -> 1 MEAS:VOLT:AC? # -> +1.41326700E+00 # 3. Silence is the default failure mode. No reply is not a timeout. Ask why: C1:HARM? # (nothing comes back at all) SYST:ERR? # -> -113,Undefined header,C1:HARM? SYST:ERR? # -> 0,"No error" queue drained # And a bonus: not everything is SCPI-shaped. This is real, working Siglent: C1:BSWV WVTP,SINE,FRQ,1000,AMPL,4 # comma bag, no colon tree. vendors do this.
| Transport | How it shows up |
|---|---|
| GPIB / IEEE-488 | The original. A parallel bus and a connector the size of a matchbox. Still everywhere in older labs. |
| RS-232 | Line-based, and identical to a socket once it is open. 9600-8-N-1 unless the manual says otherwise. |
| USBTMC | USB Test & Measurement Class, a bulk endpoint impersonating a bus. |
| Raw socket | TCP, conventionally port 5025, newline-terminated. The simplest thing that works. |
| VXI-11 | ONC RPC over TCP. Ask the portmapper on port 111 for the core port. It is dynamic, so never hard-code it. Then create_link, device_write, device_read. |
| HiSLIP | The modern replacement for VXI-11, on port 4880. |
# SCPI itself is text. What wraps it is not always text, and that is the part # that surprises people the first time they put a packet capture next to it. #=== 1. RAW SOCKET: nothing but ASCII ========================================= # The whole message. Six bytes, and you could type them by hand. 2A 49 44 4E 3F 0A '*' 'I' 'D' 'N' '?' LF # That is the entire protocol on this transport. No length prefix, no header, # no checksum. The newline is the only framing there is. #=== 2. VXI-11: the same ASCII inside a binary envelope ======================= # ONC RPC over TCP, XDR-encoded, big-endian. The command becomes a payload. 80 00 00 44 record marker: last-fragment bit | 68 bytes follow 00 00 00 07 xid, to match the reply to this call 00 00 00 00 msg_type = CALL 00 00 00 02 RPC version 2 00 06 07 AF program 395183 = VXI-11 DEVICE_CORE 00 00 00 01 program version 1 00 00 00 0B procedure 11 = device_write 00 00 00 00 cred: AUTH_NULL flavour 00 00 00 00 cred: length 0 00 00 00 00 verf: AUTH_NULL flavour 00 00 00 00 verf: length 0 00 00 00 01 the link id create_link handed back 00 00 13 88 io_timeout = 5000 ms 00 00 00 00 lock_timeout = 0 00 00 00 08 flags: END on the final byte 00 00 00 06 opaque length: 6 bytes of payload 2A 49 44 4E '*' 'I' 'D' 'N' <- the SCPI, at last 3F 0A 00 00 '?' LF, then 2 zero bytes: XDR pads to 4 # 6 bytes of SCPI, 72 bytes on the wire. The padding is not optional: XDR aligns # every opaque field to a 4-byte boundary, and an instrument will reject the call # if you leave it out. This is why a VXI-11 client is a few hundred lines and a # raw-socket client is thirty. #=== 3. THE REPLY CAN BE BINARY TOO =========================================== # Text queries answer with a line. Waveforms and screenshots answer with an # IEEE 488.2 definite-length block, which is ASCII header + raw bytes: # 8 0 0 0 0 1 0 0 0 <1000 raw bytes> │ │ └──── eight ASCII digits: the length ────┘ │ └───── how many length digits follow └─────── a block starts here # Read it by that declared length. Never read to a newline and never read until # the socket goes quiet: binary payloads contain 0x0A bytes that are data, not # terminators, and they arrive in several packets. Both mistakes truncate the # waveform and neither one raises an error. # # A leading '#0' means indefinite length instead: data runs until the terminator.
# Open a raw socket to the instrument. Port 5025 is the SCPI convention. $client = [System.Net.Sockets.TcpClient]::new('192.168.1.19', 5025) $stream = $client.GetStream() $stream.ReadTimeout = 5000 # never block forever on a silent instrument $writer = [System.IO.StreamWriter]::new($stream) $reader = [System.IO.StreamReader]::new($stream) $writer.NewLine = "`n" # plain LF. Windows would send CRLF by default $writer.AutoFlush = $true # Send one command. A line ending in ? is a query, so it answers back. $writer.WriteLine('*IDN?') $reply = $reader.ReadLine() Write-Host $reply # -> Siglent Technologies,SDM3065X,SDM36HCD801207,3.02.01.13 $client.Close()
using System.Net.Sockets; using System.Text; // Open a raw socket to the instrument. Port 5025 is the SCPI convention. using var client = new TcpClient("192.168.1.19", 5025); using var stream = client.GetStream(); stream.ReadTimeout = 5000; // never block forever on a silent instrument // SCPI is plain ASCII, newline-terminated. That trailing \n is not optional. byte[] cmd = Encoding.ASCII.GetBytes("*IDN?\n"); stream.Write(cmd, 0, cmd.Length); var buffer = new byte[1024]; int read = stream.Read(buffer, 0, buffer.Length); string reply = Encoding.ASCII.GetString(buffer, 0, read).Trim(); Console.WriteLine(reply); // -> Siglent Technologies,SDM3065X,SDM36HCD801207,3.02.01.13 // One Read is fine for a short reply. For anything longer, keep reading until // you have seen the terminating \n, or you will silently truncate the answer.
import socket # Open a raw socket to the instrument. Port 5025 is the SCPI convention. s = socket.create_connection(("192.168.1.19", 5025), timeout=5) # SCPI is plain ASCII, newline-terminated. That trailing \n is not optional. s.sendall(b"*IDN?\n") reply = s.recv(1024).decode("ascii").strip() print(reply) # -> Siglent Technologies,SDM3065X,SDM36HCD801207,3.02.01.13 s.close() # The library route, if you would rather not hand-roll the socket. pyvisa-py is a # pure-Python backend, so this needs no vendor runtime installed: # # import pyvisa # rm = pyvisa.ResourceManager("@py") # inst = rm.open_resource("TCPIP::192.168.1.19::5025::SOCKET", # read_termination="\n", write_termination="\n") # print(inst.query("*IDN?"))
Lab Equipment Controller Software
Lab Equipment Controller(GitHub repo) is a software suite I created for discovering and controlling lab instruments (oscilloscopes, function generators, …) over Ethernet or RS-232, using SCPI. It scans the local network, lists the instruments it finds, and lets you connect to several at once and drive each one from a command console, instrument-aware quick-command buttons, or a small scripting window.
Originally built as a desktop app with C# / WinForms targeting .NET 10 (net10.0-windows) and then extended so the same core engine also drives a web version, hosted in a docker container, a cross-platform CLI (lec) for benches without a desktop and for scripting a measurement into CI and the engine itself is on NuGet as LabEquipmentController, for driving instruments from your own code.
I tested the commands on a Rigol DS2202 oscilloscope, a Siglent SDG2042X generator and a Siglent SDM3065X multimeter, all answering over VXI-11.
Scan, then a console per instrument. The sweep found all three; each
opened its own tab, and the quick-command row is built from that family's catalog rather
than a fixed list. The card's heading is itself the switch, Network Scan or
Serial Scan, and it changes what the whole card is about. Here the
oscilloscope's tab is in front, being polled for peak-to-peak voltage. The log carries every
command and reply, and each numeric answer also lands in the results table with a
timestamp.
And the same bench in a browser. The web build is a control-for-control port of that window (same controls, same order, same words) served by a container that owns the sockets. Here it is against the same bench, with the meter and the generator connected and the generator's console in front. The rest of the screenshots are the desktop app.
Any tab detaches into its own window, so instruments can be watched side by side. The recorded readings plot as they arrive: pick which column runs across and which are drawn up, switch either axis to log, and save the chart or the table.
The command library browses all 36 catalogs, 23,978 commands, by
manufacturer, with the vendor's own programming guide open beside them. A ✓
marks an entry confirmed on real hardware; the filter here is showing the 206-command
Siglent SDM catalog narrowed to voltage commands, next to page 1 of the 158-page guide those
entries were transcribed from.
One script, several instruments. DEVICE binds an alias to a
model, the header shows what each resolved to on this bench, and WITH,
FOR and RECORD interleave the generator and the scope inside one
loop, which is the measurement a single-instrument script cannot express.
This is the shipped example, run as it comes: it sets the generator to a 2 Vpp sine, sets
the scope up to look at the band, then walks 20 MHz to 35 MHz in 100 kHz steps, reading
:MEASure:VRMS? at each one. 151 rows in 73 seconds, and the
curve is the low-pass section of the board below: flat at about 750 mVrms to 24 MHz, through
−3 dB at roughly 27.5 MHz, down to 181 mVrms at 35 MHz.
The bench it was measured on
Wired for the sweep above: the generator's channel 1 feeds the filter's input, and the filter's output goes to channel 1 of the scope. That is the whole of what the example's opening comment asks for.
The filter itself is one section of an RF demo board, a 30 MHz low-pass, which is why the sweep runs 20 to 35 MHz and why the curve turns over where it does.
Waveform capture pulls the trace off the scope and applies that vendor's own scaling to turn raw bytes into volts and seconds. That part is different for every manufacturer, and it is where a wrong answer looks most like a right one. Here the generator is feeding channel 1 a 20 MHz sine: 1,400 points, 2.12 V peak-to-peak across 280 ns, sampled every 200 ps. Run re-reads it on an interval so the trace follows the instrument.
A capture is drawn on cards. A card is a set of channels, so channels on one card share a vertical scale and compare directly, while a channel on its own card gets the scale to itself. Each card zooms independently (wheel, Ctrl+wheel, drag, or the three buttons), and one read of the instrument serves every card on screen.
Or describe the measurement and let a model write the script. Bring your own AI provider and Script with AI… hands it the connected instrument's catalog, 343 documented commands for this generator, and tells it to use nothing else. Every command header that comes back is checked against that catalog, and the window says so rather than letting silence read as "verified".
It is a conversation: every exchange stays on screen and goes back with the next request, which is what makes the follow-up below, "now do the same at 5 Vpp and 10 kHz", a request at all. The line above the transcript says what that history costs to send, and Clear starts again without it. Nothing is run and nothing is saved: a draft reaches the editor when you press Use This Script, and runs when you press Run.





