← Back to list

A beginner guide on programming instruments applying SCPI protocol using python

A straightforward object-oriented approach that allows for user-friendliness and expandability.

Danton Sá · 2024-02-11 14:45 · 19 claps · 10.5 min read
#python #scpi #instrument-control #oop #electrical-engineering
Open on Medium ↗
Wiki topics: 💻 · Programming

A beginner guide on programming instruments applying SCPI protocol using python

Photo by Lightsaber Collection on Unsplash

Photo by Lightsaber Collection on Unsplash

Electrical engineers who work in laboratories must constantly deal with instruments including Oscilloscopes, AC, and DC power supplies, power analyzers, and electronic loads, among others… Testbeds must be built for devices under test, which require to be connected with the instruments to simulate the operation under specific conditions. More time is spent setting up the test procedures instead of running the test itself.

Modern instruments enable users to write commands and read responses using the so-called SCPI protocol, allowing them to be controlled and programmed remotely, opening space for numerous automated routines, and even for control routines not available by the instrument’s original HMI. The SCPI protocol can be applied using any programming language, this article focuses on its application in python, and not on the SCPI syntax itself.

Agenda

1. Introduction to SCPI protocol using python

SCPI stands for “Standard Commands for Programmable Instruments”, which are straightforward string commands that one computer can send to an instrument to read or write values. Communication with the instrument is normally established using USB, LAN, Serial port, or even GPIB.

The standardization comes with the advantage that the basic commands are common to almost every instrument, like *IDN?, which queries the identification of the instrument, or MEASure:VOLTage:DC? , which would probably work on every Oscilloscope to fetch the actual DC voltage measured.

In this article, I will not focus on the syntax of SCPI but I will demonstrate how to establish low-level communication with a generic instrument using two Python built-in packages: socket, and serial which are modules that require no additional plugins and are the optimal choice for a memory-constrained system like a Raspberry PI.

Moreover, I will also introducepyvisa, which is an external package that requires an additional VISA (Virtual instrument software architecture) runtime plugin but encompasses all available communication protocols within the same high-level object-oriented approach.

A good introduction to the syntax of the SCPI language can be found on this official keysight tutorial. Some commands of course are instrument-specific. Each manufacturer provides a list of SCPI commands available for the instrument. A few of them are listed in this medium article.

If you have an instrument available that supports either USB or TCP/IP connection protocols, you can connect it to your computer and follow along with the next topics to try it out.

2. Connect instruments via USB:

If the instrument is connected via USB or serial port, the built-in serialpackage can be used and its usage is pretty straightforward. Once the instrument is connected via USB, the operating system of the computer will assign it to a port number. In Windows, the available port numbers are visible in the Device Manager, alternatively, this snippet contains a good python solution for multiple operating systems.

Once the port number of the respective instrument is determined, an instance of serial.Serial can be created and validated by querying the *IDN? command (A newline character \n is included because SCPI commands must terminate with a new line):

# validate_serial.py
import serial

COM_PORT = "COM5"  # Instrument port location
TIMEOUT = 1
CHECK_COMMAND = "*IDN?\n"  # Terminate with newline

# Open connection
serial_connection = serial.Serial(
    port=COM_PORT,
    timeout=TIMEOUT,
    write_timeout=TIMEOUT,
)
serial_connection.write(CHECK_COMMAND.encode())  # Send command
response = serial_connection.readline().decode()  # read response
serial_connection.close()  # Close connection
print(response)

The Serial Class accepts other default arguments like baudrateor parity that are instrument-dependent and might have to be adjusted depending on the instrument requirements. This example considers that the instrument accepts the default values.

If an invalid query command is sent (e.g. no question mark at the end), the instrument will not have any response message available and the program will get stuck trying to read a message that will never arrive. It is essential to set the timeout argument, which prevents the program from getting stuck and returns an empty string if no response is available after the given timeout.

Additionally, if a serial connection is attempted with an unreachable resource, the code might get stuck because a response will never be obtained. Including a value for write_timeout makes the code raise a SerialTimeoutException instead.

Another important aspect is that only bytes types can be written and read from a resource, therefore the methods encode() is applied at the command string and decode() is applied to the read string.

Since the SCPI response messages are always terminated with a new line, the method readline() is used because it stops reading when a new line is encountered, and the whole response is returned.

If the device is properly connected the code will print the respose containing the instrument's general information like model, manufacturer, version, and so on. If no message is printed, then the instrument cannot be identified.

To avoid forgetting to close the connection, a context manager can be used, it opens and closes the connection automatically, releasing the resource after use:

# validate_serial.py
import serial

COM_PORT = "COM5"  # Instrument port location
TIMEOUT = 1
CHECK_COMMAND = "*IDN?\n"  # Terminate with newline

with serial.Serial(
  COM_PORT,
  write_timeout=TIMEOUT,
  timeout=TIMEOUT) as serial_connection:
    serial_connection.write(CHECK_COMMAND.encode())  # Send command
    response = serial_connection.readline().decode()  # read response
print(response)

This example is a much cleaner, reliable, and pythonic approach.

3. Using socket for TCPIP protocol:

If the connection protocol is TCPIP, the built-in socketpackage can be used, which is not as straightforward as the serial but can be easily used once the basic functionality is implemented.

The code requires the IP address associated with the instrument in the local area network and the port used by the instrument (Normally indicated in the instrument’s SCPI protocol manual):

# validate_socket.py
import socket

IP_ADDRESS = "192.168.81.10"  # IP address of the instrument at local network
PORT = "30000"  # Port used by the instrument
TIMEOUT = 1
CHECK_COMMAND = "*IDN?\n"  # Terminate with newline

# Create socket object and connect it
socket_connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
socket_connection.settimeout(TIMEOUT)
socket_connection.connect((IP_ADDRESS, PORT))

# Send encoded message
socket_connection.sendall(CHECK_COMMAND.encode())
# Apply recv until a message with a newline at the end is received
recv_bytes = bytes(0)  # Empty bytes
while True:
    recv_bytes += socket_connection.recv(4096)
    if recv_bytes[-1:] == b"\n":  # Check if last term is a newline
        break
received = recv_bytes.decode()
socket_connection.close()

print(received)

More implementation is necessary than serial because the socket.recv() method requires a buffer size, so it is necessary to keep asking for the received bytes until a message containing the termination character \n (new line) at the end is obtained using a while statement. The buffer size of 4096 used is suggested by the socket API

Similar to serial, the socket connection can also be more reliable if used within a context manager:

import socket

IP_ADDRESS = "192.168.1.10"  # IP address of the instrument at local network
PORT = "30000"  # Port used by the instrument
TIMEOUT = 1
CHECK_COMMAND = "*IDN?\n"  # Terminate with newline

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as socket_connection:
    socket_connection.settimeout(TIMEOUT)
    socket_connection.connect((IP_ADDRESS, PORT))

    # Send encoded message
    socket_connection.sendall(CHECK_COMMAND.encode())
    # Apply recv until a message with a newline at the end is received
    recv_bytes = bytes(0)  # Empty bytes
    while True:
        recv_bytes += socket_connection.recv(4096)
        if recv_bytes[-1:] == b"\n":
            break
    received = recv_bytes.decode()

print(received)

4. Object-oriented approach for both serial and socket connections:

With the basic communication proofed, it is possible to create a base serial or socket (or any other SCPI connection protocol) Class that saves the connection object as an attribute, and the simplified versions of the most important features (write, query, and disconnect) as high-level methods, which can be used for any instrument, and inherited to accept other attributes.

The following snippets show a generic class that contains the connection object in the private attribute _connection, each call for write query and disconnect does a call for the respective base class and applies the required modifications for easier use. The advantage of this over Inheriting the serial/socket class directly is that the inheritance could compromise their basic functionality.

  • Serial instrument:
# serial_instrument.py
import serial

COM_PORT = "COM5"  # Instrument port location
TIMEOUT = 1

class SerialInstrument:
    def __init__(self,
                 port: str,
                 timeout: float | None = 1,
                 **serial_kwargs) -> None:
        self._connection = serial.Serial(
            port=port,
            timeout=timeout,
            write_timeout=timeout,
            **serial_kwargs
        )
        idn = self.query("*IDN?")  # Query identification
        if idn:
            self._idn = idn
            print(f"Connected to {idn}.")
        else:
            self.disconnect()
            print("Serial Instrument could not be identified.")

    def write(self, command: str) -> None:
        command += "\n"  # Add termination
        self._connection.write(command.encode())

    def query(self, command: str) -> str:
        self.write(command)
        read_bytes = self._connection.readline()[:-1] # Remove newline
        return read_bytes.decode()

    def disconnect(self) -> None:
        self._connection.close()

if __name__ == "__main__":
    instrument = SerialInstrument(COM_PORT, TIMEOUT)
    instrument.disconnect()
  • Socket instrument:
# socket_instrument.py
import socket

IP_ADDRESS = "192.168.81.10"  # IP address of the instrument at local network
PORT = "30000"  # Port used by the instrument

class SocketInstrument:
    def __init__(self,
                 ip_address : str,
                 tcp_port: int,
                 timeout : float | None = 1,
                 buffer_size : int = 4096) -> None:
        self._connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._connection.settimeout(timeout)
        self._connection.connect((ip_address, tcp_port))
        self.buffer_size = buffer_size
        idn = self.query("*IDN?")
        if idn:
            self._idn = idn
            print(f"Connected to {idn}.")
        else:
            self.disconnect()
            print("Socket Instrument could not be identified.")

    def write(self, command: str) -> None:
        command += "\n"  # Add termination
        self._connection.sendall(command.encode())

    def query(self, command: str) -> str:
        self.write(command)
        recv_bytes = bytes(0)
        while True:
            recv_bytes += self._connection.recv(self.buffer_size)
            if recv_bytes[-1:] == b"\n":
                return recv_bytes.decode()[:-1]

    def disconnect(self) -> None:
        self._connection.close()

if __name__ == "__main__":
    instr = SocketInstrument(IP_ADDRESS, PORT)
    instr.disconnect()

By running any of the codes in this section with the respective test instruments connected, the only visible difference, if compared with the codes from previous sections, is the printed message that now has a “connected to ” and the name of the device. But now it has the advantage of writing and querying easily using only strings without having to encode/decode bytes and check for line terminators.

The file can be used as a package and imported into another script within the same folder. This can be exemplified with the following code that connects to a generic oscilloscope and reads the actual DC voltage measured:

# oscilloscope_script.py
from socket_instrument import SocketInstrument

IP_ADDRESS = "192.168.81.10"  # IP address of the instrument at local network
PORT = "30000"  # Port used by the instrument

my_oscilloscope = SocketInstrument(IP_ADDRESS, PORT)
voltage_dc = my_oscilloscope.query(":MEASure:VOLTage:DC?")
print(voltage_dc)

Moreover, several instruments can have their child classes inherited from the connection parent’s classes. This can include even higher-level methods that make the direct interfacing between python and the SCPI protocol, allowing for a package creation for each specific instrument used in your lab, including only functionalities that are relevant to your tests, and the users do not have to type the SCPI commands directly.

# oscilloscope.py
from socket_instrument import SocketInstrument

class Oscilloscope(SocketInstrument)
    def read_voltage(self):
        query = self.query(":MEASure:VOLTage:DC?")
        return float(query)

    def read_current(self):
        query = self.query(":MEASure:CURRent:DC?")
        return float(query)

The initiator of the class is the same as the parent SocketInstrumentclass, accepting the same arguments, but now instead of having to write the whole SCPI command to read the voltage as an argument, one can simply call the read_voltage() method and retrieve the value directly in the desired type (floatin this case).

Of course, the direct SCPI protocol can still be accessed using the inherited methods write and query for functionalities that are still not implemented in the instrument’s class.

5. Composing both classes to create a common interface

Some instruments accept more than one communication interface, and different instruments in your lab do not accept all the same interfaces, but it can be cumbersome to have different parent classes for different instruments. A good approach is to create a unified Instrument class that handles any type of connection:

# instrument.py
class Instrument:
    def __init__(self,
                 com_port: str | None = None,
                 ip_address: str | None = None,
                 tcp_port: int | None = None,
                 **kwargs) -> None:
        if com_port is not None and ip_address is None and tcp_port is None:
            self._instrument = SerialInstrument(com_port, **kwargs)
        elif ip_address is not None and tcp_port is not None and com_port is None:
            self._instrument = SocketInstrument(ip_address, tcp_port, **kwargs)
        else:
            raise NameError("Invalid arguments: either 'com_port' is given for"
                            " serial connection or both 'ip_address' and "
                            "'tcp_port' are given for socket connection.")

    def write(self, command: str) -> None:
        self._instrument.write(command)

    def query(self, command: str) -> str:
        return self._instrument.query(command)

    def disconnect(self) -> None:
        self._instrument.disconnect()

In this example, a general Instrument class is built by composing both SerialInstrument and SocketInstrument classes, in which the type of connection is dictated by the given positional arguments and extra parameters such as timeout or baudrate can be included in the keyword arguments **kwargs, the parent’s classes then decide whether the keyword arguments are valid or not.

This implementation can be improved by using abstract base classes with write, read, and disconnect as abstract methods, but this theory is outside the scope of this article.

6. Using PyVISA package

A very powerful tool that can be used for any instrument that has the VISA (Virtual Instrument Software Architecture) is the package PyVISA, which includes a lot of additional features for programmable instruments, it contains a class ResourceManager that already composes the communication with multiple interfaces, and has user-friendly documentation.

The drawback PyVISA is that it requires the manual installation of an additional VISA backend library, which can compromise the performance of small-memory devices such as a Raspberry PI. On the other hand, the installed library includes a lot of tools that can be powerful on computers, such as the National Instrument’s NI-MAX.

For instance, if you connect an ethernet cable directly from an instrument to your laptop, the NI-MAX software will detect automatically the instrument’s port number (sometimes not explicitly given by the manufacturer), you can use it in your socket connection when using PyVISA.

To exemplify, the same Instrument class from the previous section is rebuilt without requiring the SocketIntrument and SerialInstrument, but depending solely on pyvisa package:

# instrument.py
import pyvisa

class Instrument:
    def __init__(self,
                 com_port: str | None = None,
                 ip_address: str | None = None,
                 tcp_port: int | None = None,
                 **kwargs) -> None:
        if com_port is not None and ip_address is None and tcp_port is None:
            port_number = ''.join(s for s in com_port if s.isdigit())
            resource_name = f"ASRL{port_number}::INSTR"
        elif ip_address is not None and com_port is None:
            if tcp_port is None:
                resource_name = f"TCPIP::{ip_address}::inst0::INSTR"
            else:
                resource_name = f"TCPIP0::{ip_address}::{tcp_port}::SOCKET"
        elif com_port == "USB":
            resource_name = 'USB0::0x0A69::0x0870::618300000186::INSTR'
        else:
            raise NameError("Invalid arguments: either 'com_port' is given for"
                            " serial connection or both 'ip_address' and "
                            "'tcp_port' are given for socket connection.")
        self._instrument = pyvisa.ResourceManager().open_resource(
            resource_name=resource_name,
            write_termination='\n',
            read_termination='\n',
            **kwargs
            )
        self._check_connection()

    def _check_connection(self):
        idn = self.query("*IDN?")
        if idn:
            self._idn = idn
            print(f"Connected to {idn}.")
        else:
            self.disconnect()
            print("Instrument could not be identified.")

    def write(self, command: str) -> None:
        self._instrument.write(command)

    def query(self, command: str) -> str:
        return self._instrument.query(command)

    def disconnect(self) -> None:
        self._instrument.close()

On the lower level, the ResourceManager class needs just a resource name that your computer uses to connect with the instrument. The initiator of the Instrument the class then directs the type of resource based on the positional arguments, and the read, write, and disconnect methods are already composed in the low-level implementation of pyvisa.

As mentioned before, custom instrument classes for oscilloscopes, power supplies, electronic loads, and many other devices that support the SCPI protocol can be created and stored in different packages, so that everyone in your company can install them, and control the instruments remotely using a high-level python object-oriented implementation without requiring to write the SCPI commands directly.

7. Takeaways

Now that basic functionality is implemented, a lot of features can be added to the base Instrument class, such as connection overtake, timeout and reconnection attempts, logging and debugging tools, threads for connection checks, and many more features. These new features are useful for every specific instrument child class.

Nevertheless, each instrument class can be improved with specific functionalities, such as high-level interfacing, custom foolproof limiting of input-output values, customized loggings with .csv files, and many more.

With well-developed instrument packages, test procedures and multithreading applications can be written so that one instrument can have feedback information from other instruments, and a closed-loop control system can be developed for countless purposes, such as maintaining a constant temperature in a testing environment.

But these are all themes for future articles…


메타데이터
post_id
e415e328cdc1
slug
a-beginner-guide-on-programming-instruments-applying-scpi-protocol-using-python-e415e328cdc1
url
https://medium.com/@dantonsa/a-beginner-guide-on-programming-instruments-applying-scpi-protocol-using-python-e415e328cdc1
canonical_url
https://medium.com/@dantonsa/a-beginner-guide-on-programming-instruments-applying-scpi-protocol-using-python-e415e328cdc1
author_url
https://medium.com/@dantonsa
status
ok
fetched_at
2026-06-28 04:42:08