---
title: SCPI Instrument Automation with Python
description: "Automate SCPI instruments from Python with PyVISA: resource strings, a first script, error queues, *OPC? sync, timeouts, binary data, and drivers."
url: https://galoislabs.ai/blog/scpi-automation-python
author: Alex Hernandez
author_url: https://galoislabs.ai/blog/authors/alex-hernandez
published: "2026-06-06"
topic: Instrument automation
publisher: Galois Labs
---

# SCPI instrument automation with Python: from first script to production bench

![A bench digital multimeter in front elevation, blank display, four input jacks and a LAN cable, with three empty callout circles.](https://galoislabs.ai/blog/figures/bench-1.light.webp)

*FIG. 1 — BENCH DMM, FRONT PANEL*

To automate a SCPI instrument from Python, open a VISA resource with PyVISA, send SCPI strings with `write()` and `query()`, and parse the text that comes back. The first script fits on one screen. A production script adds error-queue checks, `*OPC?` synchronization, deliberate timeouts and termination, and a driver layer that keeps raw command strings out of your tests.

This guide builds that path on one instrument, the Keysight 34461A multimeter, with commands from Keysight's [Truevolt operating and service guide](https://www.keysight.com/us/en/assets/9018-03876/service-manuals/9018-03876.pdf) and code for the current PyVISA 1.16 API. Swap in your own instrument's commands; the structure carries over. Worked examples cover a [Keithley SMU](https://galoislabs.ai/blog/keithley-smu-python), a [Yokogawa WT power analyzer](https://galoislabs.ai/blog/yokogawa-power-analyzer-automation), an [electronic load](https://galoislabs.ai/blog/electronic-load-automation) and an [optical bench](https://galoislabs.ai/blog/photonics-test-automation).

## What is SCPI?

SCPI, Standard Commands for Programmable Instruments, is a text command language for test instruments. The [SCPI-99 specification](https://www.ivifoundation.org/downloads/SCPI/scpi-99.pdf), hosted by the IVI Foundation, builds on IEEE 488.2: every SCPI instrument implements the mandatory 488.2 common commands, and SCPI adds a required error queue and status system. Three parts matter day to day.

**Common commands** start with an asterisk and mean the same thing on every compliant instrument. `*IDN?` returns four comma-separated fields: manufacturer, model, serial number and firmware. `*RST` resets settings, `*CLS` clears the status registers and the error queue, and `*OPC?` returns `1` once pending operations finish.

**The error queue** is read with `SYSTem:ERRor[:NEXT]?`, which every SCPI instrument must implement. It returns the oldest entry as `code,"message"`, first in, first out, and `0,"No error"` once the queue is empty. Standard codes are negative and grouped by class: `-100` command errors, `-200` execution errors, `-300` device-specific errors, `-400` query errors.

**The command tree.** Instrument commands are colon-separated paths such as `MEASure:VOLTage:DC?`. The uppercase letters are the short form, so `MEAS:VOLT:DC?` is the same command. Bracketed nodes are optional, and a trailing `?` makes a query. An instrument accepts only the exact short form or the exact long form, so `MEASU:VOLT:DC?` is an error, not a near miss.

SCPI standardizes grammar and a shared vocabulary, not behavior. Which subsystems an instrument implements, its ranges, and which of its commands keep running after they return are all in its own programming guide. That gap is why drivers exist.

## Which VISA resource string does each transport use?

PyVISA addresses every instrument with a VISA resource string, and the format of the string selects the protocol. The formats below follow [PyVISA's resource name table](https://pyvisa.readthedocs.io/en/latest/introduction/names.html), where `board` defaults to 0 and the VXI-11 device name defaults to `inst0`.

| Transport  | Format                                       | Example                                   | Port and discovery                                     |
| ---------- | -------------------------------------------- | ----------------------------------------- | ------------------------------------------------------ |
| GPIB       | `GPIB[board]::primary[::secondary][::INSTR]` | `GPIB0::22::INSTR`                        | Bus address; needs a GPIB adapter driver               |
| USBTMC     | `USB[board]::vendor::model::serial[::INSTR]` | `USB0::0x2A8D::0x0101::MY54505555::INSTR` | Enumerated by `list_resources()`                       |
| VXI-11     | `TCPIP[board]::host[::inst0][::INSTR]`       | `TCPIP0::192.168.1.50::inst0::INSTR`      | RPC through the portmapper on 111; broadcast discovery |
| HiSLIP     | `TCPIP[board]::host::hislip0[::INSTR]`       | `TCPIP0::192.168.1.50::hislip0::INSTR`    | TCP 4880; mDNS discovery                               |
| Raw socket | `TCPIP[board]::host::port::SOCKET`           | `TCPIP0::192.168.1.50::5025::SOCKET`      | Vendor-chosen port; never discovered                   |
| Serial     | `ASRL[board][::INSTR]`                       | `ASRL/dev/ttyUSB0::INSTR`                 | Baud rate and termination set by hand                  |

When an instrument offers both LAN protocols, prefer HiSLIP. The [HiSLIP specification, IVI-6.1](https://www.ivifoundation.org/downloads/Protocol%20Specifications/IVI-6.1_HiSLIP-2.0-2020-04-23.pdf), names VXI-11 as its primary predecessor and carries GPIB-style features over two TCP connections: device clear, service requests, locking and end-of-message. Version 2.0 adds optional encryption and client authentication where the instrument supports them.

A raw socket is the simplest transport and the most fragile: no end-of-message marker, no device clear and no locking at the protocol level. The 34461A takes SCPI on socket port 5025 and Telnet on 5024; other vendors choose other ports, so check the manual. On Windows, serial port COM3 is `ASRL3::INSTR`.

**Do you need NI-VISA?** No. With no argument, `pyvisa.ResourceManager()` uses an installed IVI VISA library (NI-VISA, Keysight VISA, R&S VISA, TekVISA) and otherwise [falls back to PyVISA-py](https://pyvisa.readthedocs.io/en/latest/introduction/configuring.html), a pure-Python backend you can force with `ResourceManager("@py")`. [PyVISA-py needs one optional package per bus](https://pyvisa.readthedocs.io/projects/pyvisa-py/en/latest/installation.html): PyUSB plus libusb for USBTMC, PySerial for serial, linux-gpib or gpib-ctypes for GPIB, and psutil and zeroconf for full LAN discovery. GPIB on Windows and macOS still needs a vendor driver. [PyVISA on Linux](https://galoislabs.ai/blog/pyvisa-linux-instrument-control) covers udev rules, linux-gpib and LXI discovery.

## How do I write my first SCPI script in Python?

```python title="idn.py"
import pyvisa

rm = pyvisa.ResourceManager()  # IVI VISA if installed, otherwise PyVISA-py
print(rm.list_resources())     # ::INSTR resources only

dmm = rm.open_resource(
    "TCPIP0::192.168.1.50::hislip0::INSTR",
    read_termination="\n",
    write_termination="\n",
    timeout=5_000,             # milliseconds
)
print(dmm.query("*IDN?"))      # manufacturer,model,serial,firmware
volts = float(dmm.query("MEAS:VOLT:DC?"))
print(f"{volts:+.6e} V")

dmm.close()
rm.close()
```

Four details in these lines carry into every later script.

- **Discovery is partial.** `list_resources()` filters on `?*::INSTR` by default, so [socket and USB raw resources are not listed](https://pyvisa.readthedocs.io/en/latest/introduction/communication.html). Socket resources are never discovered at all; you type their address. For quick checks, `pyvisa-shell` offers `list`, `open` and `query` at a prompt.
- **Termination is explicit.** The 34461A ends every response with a newline and accepts a newline, or a carriage return plus newline, as the end of a command. Setting both terminations to `"\n"` matches it on every transport, including raw sockets, where it is mandatory.
- **`*IDN?` is configurable.** For compatibility with older programs, a 34461A can be set to identify itself as an Agilent 34461A or an HP 34401A. Match identity with a pattern, and keep the full string for your records.
- **`MEAS:VOLT:DC?` is a one-shot.** It configures DC volts with defaults (autorange, 10 power-line cycles of integration), triggers, and returns one reading such as `+4.23450000E-03`, which `float()` parses directly.

## What does a production SCPI script need?

The first script assumes every command worked, the instrument kept up, and nothing else was talking to it. Production code checks each assumption, one section at a time; the driver section then assembles the pieces.

### Check the error queue after every step

Instruments do not raise exceptions. A mistyped header or an out-of-range value lands in the error queue, the instrument carries on, and so does your script, now measuring with the wrong settings. Read the queue until it reports code 0:

```python title="bench/errors.py"
from pyvisa.resources import MessageBasedResource


class InstrumentError(RuntimeError):
    pass


def drain_errors(inst: MessageBasedResource, limit: int = 32) -> list[tuple[int, str]]:
    """Read SYST:ERR? until the queue reports code 0; return what it held."""
    errors: list[tuple[int, str]] = []
    for _ in range(limit):
        code, _, message = inst.query("SYST:ERR?").partition(",")
        if int(code) == 0:  # Keysight answers +0,"No error": parse the number
            return errors
        errors.append((int(code), message.strip().strip('"')))
    raise InstrumentError(f"error queue not empty after {limit} reads: {errors}")


def checked_write(inst: MessageBasedResource, command: str) -> None:
    inst.write(command)
    if errors := drain_errors(inst):
        raise InstrumentError(f"{command}: {errors}")
```

The [34461A's `SYSTem:ERRor?` reference](https://www.keysight.com/us/en/assets/9018-03876/service-manuals/9018-03876.pdf) explains the shape of this loop:

- The empty response is `+0,"No error"`, with a plus sign, so the loop parses the number instead of comparing strings.
- The queue holds 20 entries. On overflow, the newest entry becomes `-350,"Queue overflow"` and later errors are dropped, so start each session with `*CLS`.
- Each I/O session (GPIB, USB, VXI-11, Telnet and sockets) has its own queue, and errors appear on the session that caused them. Read the queue on the connection that sent the command.

One more trap: a malformed query usually produces no response, so it surfaces as a timeout. After a query times out, drain the queue before deciding what went wrong. Each check costs a round trip; when throughput matters, check once per configuration block, but do not skip it.

### Synchronize with `*OPC?`, not `sleep()`

Some commands are overlapped: the instrument accepts them and moves on before the work is done. On the 34461A, `INITiate` is overlapped, so your script gets control back while readings are still being taken. `*OPC?` returns `1` only after all pending operations finish, and [SCPI requires `*OPC?` itself to be sequential](https://www.ivifoundation.org/downloads/SCPI/scpi-99.pdf) so nothing overtakes it. This is Keysight's own example, plus the fetch:

```python title="sync.py"
dmm.write("CONF:VOLT:DC")
dmm.write("SAMP:COUN 100")
dmm.write("INIT")
dmm.query("*OPC?")  # returns "1" once all 100 readings are in memory
readings = dmm.query_ascii_values("FETC?")
```

Use the same pattern after `*RST` (`dmm.query("*RST;*CLS;*OPC?")`), after changing a source output, and before checking errors on an overlapped command. A `time.sleep()` guess is too short on a slow day and wasted time on a fast one. One catch: `*OPC?` waits inside a read, so the wait counts against the I/O timeout.

### Set timeouts and termination deliberately

The VISA default I/O timeout is 2,000 ms ([VPP-4.3](https://www.ivifoundation.org/downloads/VISA/vpp43_2024-01-04.pdf)), and PyVISA exposes it per resource, in milliseconds, as `timeout`. The example above uses the 34461A default of 10 power-line cycles per reading. One hundred readings take 1,000 line cycles: 16.7 seconds at 60 Hz or 20 seconds at 50 Hz, before autozero adds its extra zero measurements. With the default timeout, the `*OPC?` read fails after two seconds. Budget the timeout from the measurement and scope it to the slow step:

```python title="bench/timeouts.py"
from contextlib import contextmanager

from pyvisa import constants
from pyvisa.errors import VisaIOError


@contextmanager
def io_timeout(inst, ms: int):
    """Raise the timeout for one slow step, then put it back."""
    previous = inst.timeout
    inst.timeout = ms
    try:
        yield inst
    finally:
        inst.timeout = previous


def acquire(dmm, readings: int, nplc: float, line_hz: float = 60.0) -> list[float]:
    budget_ms = readings * nplc / line_hz * 1_000
    try:
        with io_timeout(dmm, int(budget_ms * 3) + 2_000):  # headroom for autozero
            dmm.write("INIT")
            dmm.query("*OPC?")
        return dmm.query_ascii_values("FETC?")
    except VisaIOError as exc:
        if exc.error_code == constants.StatusCode.error_timeout:
            dmm.clear()  # flush buffers so a late reply isn't read as the next answer
        raise
```

The `except` branch matters as much as the budget: after a timeout, the instrument may still send its late answer, and the next `query()` would read it as its own. `clear()` is VISA's device clear: GPIB, USBTMC, VXI-11 and HiSLIP each carry a clear message to the instrument, but [on a raw socket VPP-4.3 only requires flushing local buffers](https://www.ivifoundation.org/downloads/VISA/vpp43_2024-01-04.pdf). On sockets, close and reopen the connection instead.

Termination follows the same logic. GPIB, USBTMC, VXI-11 and HiSLIP mark the end of each message, so reads stop on their own. Raw sockets and serial lines do not, and without `read_termination` a read waits out the timeout. Set both terminations from the manual.

### Move bulk data as binary blocks

ASCII is fine for single readings; for arrays, ask for binary. With `FORM:DATA REAL,64`, the 34461A sends each reading as an 8-byte IEEE 754 double inside an IEEE 488.2 definite-length block: `#`, one digit giving how many digits the byte count has, the byte count, then the payload. An ASCII reading carries 9 significant digits, about 16 characters with sign, exponent and separator, so binary halves the transfer and skips string parsing.

```python title="binary.py"
import numpy as np

from bench.timeouts import io_timeout

dmm.write("CONF:VOLT:DC 10")
dmm.write("VOLT:DC:NPLC 0.2")
dmm.write("SAMP:COUN 1000")
dmm.write("FORM:DATA REAL,64")

with io_timeout(dmm, 15_000):  # 1,000 x 0.2 PLC is 3.3 s at 60 Hz before autozero
    volts = dmm.query_binary_values(
        "READ?",
        datatype="d",        # REAL,64: 8-byte double
        is_big_endian=True,  # NORMal byte order: most significant byte first
        container=np.array,
    )
dmm.write("FORM:DATA ASC")   # leave the meter as other scripts expect it
```

`query_binary_values()` parses the block header for you; `header_fmt="ieee"` is the default. [Its other defaults](https://pyvisa.readthedocs.io/en/latest/introduction/rvalues.html) are `datatype="f"`, a 4-byte float, and little-endian byte order, which would silently misread this data. On the Truevolt family, `FORMat:BORDer` can swap the byte order only on the 34465A and 34470A; the default NORMal order sends the most significant byte first, hence `is_big_endian=True`. `container=np.array` returns a NumPy array. The [photonics guide](https://galoislabs.ai/blog/photonics-test-automation) reads little-endian blocks from a Keysight 8164B.

### Give each instrument one owner

[PyVISA's FAQ](https://pyvisa.readthedocs.io/en/latest/faq/faq.html) says the library has been thread safe since version 1.6. That protects PyVISA's own state, not your measurement. A reading is a transaction (configure, trigger, wait, fetch, check errors), and another thread's commands slipped in between change the instrument underneath it. Hold one lock per instrument for the whole transaction:

```python title="owner.py"
import threading

dmm_lock = threading.RLock()  # one per instrument, shared by every thread


def read_block(dmm, count: int) -> list[float]:
    with dmm_lock:  # configure, trigger, wait and fetch as one transaction
        dmm.write(f"SAMP:COUN {count}")
        dmm.write("INIT")
        dmm.query("*OPC?")
        return dmm.query_ascii_values("FETC?")
```

Across processes, VISA offers locks: open the resource with `access_mode=AccessModes.exclusive_lock`, or wrap a transaction in `with dmm.lock_context():`. Support depends on the backend. PyVISA-py implements locking only for some network sessions, and on the others `lock_context()` raises `VI_ERROR_NSUP_OPER`. The dependable design is one process that owns each instrument and serves everyone else.

### Log every command for traceability

When a result looks wrong a month later, the question is what the instrument was actually told. Log every write and query with a timestamp, the resource, the response and the elapsed time, and start each session with the full `*IDN?` string so every line ties to one serial number and firmware revision. This class folds the log, the lock and the error check into one object that owns the resource:

```python title="bench/session.py"
import json
import logging
import threading
import time

import pyvisa

from bench.errors import InstrumentError, drain_errors

log = logging.getLogger("bench.scpi")


class Session:
    """One owner per instrument: every command serialized, logged and error-checked."""

    def __init__(self, rm: pyvisa.ResourceManager, resource: str, timeout_ms: int = 5_000):
        self.inst = rm.open_resource(
            resource, read_termination="\n", write_termination="\n", timeout=timeout_ms
        )
        self.resource = resource
        self.lock = threading.RLock()
        self.inst.write("*CLS")         # start from an empty error queue
        self.idn = self.query("*IDN?")  # first log line names the unit and its firmware

    def _record(self, op: str, command: str, result, started: float) -> None:
        log.info(json.dumps({
            "t": time.time(), "resource": self.resource, "op": op, "command": command,
            "result": result, "ms": round((time.perf_counter() - started) * 1e3, 1),
        }))

    def write(self, command: str) -> None:
        with self.lock:
            started = time.perf_counter()
            self.inst.write(command)
            errors = drain_errors(self.inst)
            self._record("write", command, errors or None, started)
            if errors:
                raise InstrumentError(f"{self.resource} {command}: {errors}")

    def query(self, command: str) -> str:
        with self.lock:
            started = time.perf_counter()
            response = self.inst.query(command)
            self._record("query", command, response, started)
            return response

    def query_block(self, command: str, **kwargs):
        with self.lock:
            started = time.perf_counter()
            values = self.inst.query_binary_values(command, **kwargs)
            self._record("query_block", command, f"{len(values)} values", started)
            return values
```

JSON lines are easy to search, diff between runs and attach to a test report. The lock is reentrant, so a driver method can hold it across a whole transaction. [Galois sequences](https://galoislabs.ai/product) keep the same kind of record for every step: the SCPI sent, the raw response, the measured value and its limits.

### Reach instruments on another machine

LAN instruments are reachable from any machine that can route to them. GPIB, USB and serial instruments are reachable only from the PC they are plugged into, so remote access means running something on that PC. NI-VISA has its own remote resource syntax, `visa://host/resource`. The `pyvisa-galois` backend takes a different route: change one line, and every VISA call travels over HTTPS to Galois Cloud, which relays it to the galois-edge daemon on the bench PC.

```python title="remote.py"
# install pyvisa-galois from the Galois package index (see the PyVISA backend docs)
# reads GALOIS_BACKEND_URL and GALOIS_AUTH_TOKEN from the environment
import pyvisa

rm = pyvisa.ResourceManager("@galois")  # was: pyvisa.ResourceManager()
print(rm.list_resources())

dmm = rm.open_resource("USB0::0x2A8D::0x0101::MY54505555::INSTR")
print(dmm.query("*IDN?"))
```

The script needs no NI-VISA and no local USB or GPIB drivers, and the backend handles instrument locking and timeouts. The [PyVISA backend docs](https://docs.galoislabs.ai/guides/pyvisa/) list the operations that carry over, among them `query`, `write`, timeouts, `access_mode` locking and `query_binary_values`; check that list before you port an existing script. To have Galois draft and run a test from a plain-English objective, see [the agent walkthrough below](https://galoislabs.ai/blog/scpi-automation-python#how-do-i-run-this-rail-check-in-galois-with-évariste). Whatever path you choose, keep raw socket and VXI-11 ports off shared networks: neither protocol authenticates the client.

## How do I turn scripts into drivers?

Raw strings scattered across test scripts age badly. Every script relearns that this meter accepts `VOLT:DC:NPLC` values from a fixed list, that its binary data is big-endian, and how long 100 readings take. A driver puts that knowledge in one class with typed methods, and tests call the methods:

```python title="bench/keysight_34461a.py"
import re

import numpy as np

from bench.session import Session
from bench.timeouts import io_timeout


class Keysight34461A:
    """Typed methods over raw SCPI. Tests call these and never see a command string."""

    IDN = re.compile(r"(Keysight|Agilent) Technologies,34461A,")
    NPLC = (0.02, 0.2, 1, 10, 100)

    def __init__(self, session: Session, line_hz: float = 60.0):
        if not self.IDN.match(session.idn):
            raise ValueError(f"{session.resource} is not a 34461A: {session.idn}")
        self.s, self.line_hz = session, line_hz

    def dc_volts(self, count: int = 1, range_v: float = 10, nplc: float = 1) -> np.ndarray:
        if nplc not in self.NPLC:
            raise ValueError(f"nplc must be one of {self.NPLC}")
        budget_ms = count * nplc / self.line_hz * 1_000
        with self.s.lock:  # configure, measure and read back as one transaction
            for command in (f"CONF:VOLT:DC {range_v}", f"VOLT:DC:NPLC {nplc}",
                            f"SAMP:COUN {count}", "FORM:DATA REAL,64"):
                self.s.write(command)
            with io_timeout(self.s.inst, int(budget_ms * 3) + 2_000):
                return self.s.query_block(
                    "READ?", datatype="d", is_big_endian=True, container=np.array
                )
```

```python title="test_rail.py"
import pyvisa

from bench.keysight_34461a import Keysight34461A
from bench.session import Session

rm = pyvisa.ResourceManager()
dmm = Keysight34461A(Session(rm, "TCPIP0::192.168.1.50::hislip0::INSTR"))

volts = dmm.dc_volts(count=100, nplc=1)
assert 3.25 <= volts.mean() <= 3.35, f"3.3 V rail at {volts.mean():.4f} V"
```

The test now reads as intent: measure the 3.3 V rail 100 times at 1 PLC and check the mean. Arguments are validated before anything reaches the instrument, the timeout follows from them, and every command still lands in the log.

A hand-written class per model works while a bench has a handful of models. As the count grows, the commands, parameters and ranges are better written as data, with the methods generated from it; [declarative instrument drivers](https://galoislabs.ai/blog/declarative-instrument-drivers) covers that trade-off. Galois takes the declarative route and ships 573 instrument profiles across 135 manufacturers ([instrument library](https://galoislabs.ai/instruments)); when `pyvisa-galois` opens a matched instrument, the profile's commands appear as keyword-only methods on the resource, such as `smu.set_voltage(voltage=1.5)`. To write one yourself, see [adding a SCPI instrument profile](https://galoislabs.ai/blog/add-scpi-instrument-profile).

## How do I run this rail check in Galois with Évariste?

Évariste, the agent in the Galois platform, builds the same check as `test_rail.py` from a plain-English objective, with the instrument's profile in the role of `Session` and `Keysight34461A`. Open it beside your project from the app sidebar (Ctrl+Shift+E) and state the task with the limits from your datasheet:

> Create a sequence for the Keysight 34461A: set the 10 V DC range and 1 PLC, take 100 readings of the 3.3 V rail, and pass if the mean is between 3.25 V and 3.35 V.

Évariste lists the instruments connected to your team's galois-edge daemons and reads the 34461A's profile commands. For a meter outside the library, it generates a profile from the programming manual you upload; after you review it, Évariste deploys it to the bench's daemon and binds it to the instrument. It then drafts a sequence of named profile commands, with the limit on the last step:

```yaml title="rail_3v3_mean.yaml"
name: "3.3 V rail, mean of 100 readings"
steps:
  - name: "Reset meter"
    type: action
    config:
      instrument_id: "dmm"
      command_name: "reset"

  - name: "10 V DC range"
    type: action
    config:
      instrument_id: "dmm"
      command_name: "voltage_dc_range"
      parameters: { range: "10" }

  - name: "Integration 1 PLC"
    type: action
    config:
      instrument_id: "dmm"
      command_name: "voltage_dc_nplc"
      parameters: { nplc: "1" }

  - name: "100 readings per trigger"
    type: action
    config:
      instrument_id: "dmm"
      command_name: "sample_count"
      parameters: { count: "100" }

  - name: "Enable statistics"
    type: action
    config:
      instrument_id: "dmm"
      command_name: "calculate_average_state"
      parameters: { state: "ON" }

  - name: "Take readings"
    type: action
    config:
      instrument_id: "dmm"
      command_name: "read"

  - name: "Mean of 3.3 V rail"
    type: numeric_limit
    config:
      instrument_id: "dmm"
      command_name: "calculate_average_average"
      low_limit: 3.25
      high_limit: 3.35
      unit: "V"
      comparison: "GELE"
```

The `read` step sends `READ?` to take the 100 readings, and with statistics on, the meter averages them. The limit applies to that mean (`CALCulate:AVERage:AVERage?`), the number `volts.mean()` computes in the code path. Termination and the default timeout are profile settings.

The sequence lands as a draft that does not run until an engineer approves it. Before approving, check the instrument, range, integration time and sample count, that the limit sits on the mean rather than a single reading, and both limits against the datasheet; [how to review an AI-generated test plan](https://galoislabs.ai/blog/review-ai-generated-test-plan) lists what else to check. Ask Évariste for changes in conversation or edit in the sequence builder. Every change is a new version with history and diffs, and you can lock the approved sequence for production.

Wiring the meter to the rail, powering the board and bench safety stay with you. When you start the run, galois-edge executes it on the bench and Monitor shows the channels live. If you send a command flagged as dangerous straight from the conversation, Évariste asks you to confirm it first.

Each step is recorded with its measured value, limits, pass or fail, the raw command and response, instrument, operator, DUT serial and timestamps. That covers the command trail `Session` logs and adds the test context around it. Ask Évariste which steps failed or passed close to a limit, or how this board compares with the last run; answers cite the runs and notes they draw on. The prompt "Generate a test report from the last run" produces a PDF or HTML report from a LaTeX template; add the range, PLC and sample count you chose, and why, in the report editor, and results can go to Slack.

Your job is the objective, the limits from the datasheet, the review, the approval and the bench setup. The session class, driver class, error handling, logging and report script are no longer yours to write or maintain.

| Step                     | Code path (this guide)                                     | Galois with Évariste                                               |
| ------------------------ | ---------------------------------------------------------- | ------------------------------------------------------------------ |
| Find the instrument      | `list_resources()` and an `*IDN?` pattern                  | "List connected instruments" across the team's galois-edge daemons |
| Driver                   | `Session` and `Keysight34461A` classes                     | Library profile, or one generated from the uploaded manual         |
| Define the test          | `test_rail.py`: 100 readings at 1 PLC, mean 3.25 to 3.35 V | Plain-English objective, same limits; Évariste drafts the sequence |
| Timeouts and termination | Set per resource and per slow step                         | Profile settings                                                   |
| Review                   | Code review of the script and driver                       | Draft reviewed, edited with versioned diffs, approved              |
| Run                      | `python test_rail.py` on the bench PC                      | Run through galois-edge, watched in Monitor                        |
| Record                   | JSON-lines log from `Session`                              | Per-step value, limits, pass/fail, raw I/O, operator, DUT serial   |
| Interpret                | Read the assertion message and the log                     | Évariste flags failures and near-limit passes and compares runs    |
| Report                   | Your own script over the JSON log                          | Generated PDF or HTML report, shareable to Slack                   |

## How do I scale SCPI automation past one bench?

Everything above runs on one PC and one bench. With several benches, the problems become inventory (which instrument is where), access (who may drive it, from where) and records (what ran, on which unit). That is the layer Galois builds.

![Galois instruments inventory for a demo team: model, manufacturer, class, address, connection type, edge, and connection status for SCPI, serial, and Modbus instruments across several bench machines. Demo data.](https://galoislabs.ai/blog/scpi-automation-python/instruments-light.webp)

*FIG. 2 — Instrument inventory across edges (demo data)*

Galois is agent-driven test engineering for hardware teams: agents generate tests and instrument drivers, run them on real benches through the open-source galois-edge daemon, and turn the results into reports and a shared engineering record.

The daemon is the part that matters for this guide. galois-edge is Apache-2.0 and runs on the bench PC or a Raspberry Pi. At startup and on a rescan interval, it [walks every enabled bus](https://docs.galoislabs.ai/guides/connecting-instruments/) (GPIB, USBTMC, LAN through mDNS plus a static list, serial, Modbus and CAN), identifies SCPI instruments with `*IDN?`, and matches each reply against profile patterns. Instruments without a profile still accept raw SCPI. From Python there are [three entry points](https://docs.galoislabs.ai/guides/python-sdk/):

- **`pyvisa.ResourceManager("@galois")`** for existing PyVISA code, with one changed line.
- **The typed `galois` SDK**, where `galois.Edge.connect("lab-pi:50051")` opens a daemon connection whose instrument objects offer `query()`, profile commands through `execute()`, NumPy-decoded waveform streams, and sweeps that keep running on the daemon if the client drops.
- **Raw gRPC** against the `edge.proto` contract, for tooling in other languages.

The same daemon serves agents. It exposes connected instruments as typed Model Context Protocol tools, such as `keysight_34461a__measure_voltage_dc`, plus a generic `send_scpi` tool for anything a profile does not cover, with [the same audit log as the gRPC API](https://docs.galoislabs.ai/agents/). [AI test automation for hardware benches](https://galoislabs.ai/blog/ai-test-automation-hardware) covers what that changes for a test team, and [MCP for lab instruments](https://galoislabs.ai/blog/mcp-lab-instruments) covers connecting a client. The platform also runs as a dedicated single-tenant cloud or fully on-prem and air-gapped ([deployment options](https://galoislabs.ai/deployment)).

If one engineer owns one bench, PyVISA plus a session class and a few drivers is enough, and the [Galois and PyVISA comparison](https://galoislabs.ai/compare/pyvisa) shows where the line falls. Teams moving off LabVIEW can start with the [LabVIEW to Python migration plan](https://galoislabs.ai/blog/labview-to-python-migration). To run tests on every commit, see [hardware tests in CI](https://galoislabs.ai/blog/hardware-tests-in-ci); for other open-source stacks, see [open-source instrument control software, compared](https://galoislabs.ai/blog/open-source-instrument-control). To try the daemon on your own bench, start with the [quickstart](https://docs.galoislabs.ai/getting-started/quickstart/).

## Frequently asked questions

### What is SCPI?

SCPI (Standard Commands for Programmable Instruments) is a text command language for test instruments, built on IEEE 488.2. Commands form a colon-separated tree such as MEASure:VOLTage:DC?, every compliant instrument implements common commands like *IDN?, *RST and *OPC?, and errors are reported through a queue you read with SYSTem:ERRor?.

### Do I need NI-VISA to use PyVISA?

No. PyVISA uses an installed IVI VISA library such as NI-VISA or Keysight VISA when one is present, and otherwise falls back to PyVISA-py, a pure-Python backend you can also select with ResourceManager('@py'). PyVISA-py handles LAN instruments out of the box, and USBTMC and serial through optional packages (PyUSB, PySerial); GPIB on Windows and macOS still needs a vendor driver.

### How do I find my instrument's VISA resource string?

Call list_resources() on a PyVISA ResourceManager, or run pyvisa-shell and type list. Both show the GPIB, USB, VXI-11, HiSLIP and serial instruments your backend can discover. Raw socket resources are never discovered, so build them from the instrument's IP address and port, for example TCPIP0::192.168.1.50::5025::SOCKET. With galois-edge installed, galois-edge status lists every instrument the daemon found with its resource string.

### Can I send SCPI commands over Ethernet?

Yes. LAN instruments accept SCPI over VXI-11 (TCPIP0::host::inst0::INSTR), HiSLIP (TCPIP0::host::hislip0::INSTR, TCP port 4880) or a raw TCP socket (TCPIP0::host::5025::SOCKET on instruments such as the Keysight 34461A that listen on port 5025). PyVISA handles all three. On a raw socket, set read_termination yourself, because the protocol has no end-of-message marker.

### Can I automate SCPI instruments without writing Python?

Yes. In Galois, you give Évariste, the agent in the Galois platform, a plain-English objective with your limits, such as 100 DC-volt readings on a Keysight 34461A at 1 PLC with a mean between 3.25 V and 3.35 V, and it drafts a versioned sequence from the instrument's profile. You review and approve the draft, run it on the bench through galois-edge, and Évariste reads the per-step results and generates the test report.
